diff --git a/.github/skills/resilience-management-operations/references/development.md b/.github/skills/resilience-management-operations/references/development.md index a3b56554c..d29f21d30 100644 --- a/.github/skills/resilience-management-operations/references/development.md +++ b/.github/skills/resilience-management-operations/references/development.md @@ -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 `-drills` and `-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 `-az`, 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. \ No newline at end of file +- 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. \ No newline at end of file diff --git a/.github/skills/resilience-management-operations/references/payloads.md b/.github/skills/resilience-management-operations/references/payloads.md index 4be9a7fc0..cbb38445b 100644 --- a/.github/skills/resilience-management-operations/references/payloads.md +++ b/.github/skills/resilience-management-operations/references/payloads.md @@ -1,9 +1,54 @@ # Resilience Payload Reference - + 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. diff --git a/.github/skills/resilience-management-operations/references/tools.md b/.github/skills/resilience-management-operations/references/tools.md index 7ff933445..74280d1a9 100644 --- a/.github/skills/resilience-management-operations/references/tools.md +++ b/.github/skills/resilience-management-operations/references/tools.md @@ -2,14 +2,14 @@ -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 | diff --git a/.github/skills/resilience-management-operations/references/workflows.md b/.github/skills/resilience-management-operations/references/workflows.md index b6c266af7..111b5fe33 100644 --- a/.github/skills/resilience-management-operations/references/workflows.md +++ b/.github/skills/resilience-management-operations/references/workflows.md @@ -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. diff --git a/Directory.Packages.props b/Directory.Packages.props index d13ec7073..25ab30052 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -52,7 +52,7 @@ - + diff --git a/servers/Azure.Mcp.Server/changelog-entries/resiliency-drills-ga-sdk.yaml b/servers/Azure.Mcp.Server/changelog-entries/resiliency-drills-ga-sdk.yaml new file mode 100644 index 000000000..ddf3d5c48 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/resiliency-drills-ga-sdk.yaml @@ -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." diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 94403a3e1..d448a4dff 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -4580,6 +4580,7 @@ azmcp resiliency recoveryjob resource get --service-group \ [--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 \ --drill \ @@ -4588,7 +4589,9 @@ azmcp resiliency drill create --service-group \ --drill-type \ --rbac-setup-mode \ [--resource-group ] \ - [--recoveryplan ] + [--goal-assignment-properties ] \ + [--health-model-monitoring-properties ] \ + [--sli-monitoring-properties ] # 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 \ --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 \ --drill \ [--subscription --region ] \ [--rbac-setup-mode ] \ - [--recoveryplan ] + [--goal-assignment-properties ] \ + [--health-model-monitoring-properties ] \ + [--sli-monitoring-properties ] # Delete a resilience drill from a service group # ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired @@ -4626,11 +4632,12 @@ azmcp resiliency drill delete --service-group \ azmcp resiliency drill check-resync-readiness --service-group \ --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 \ --drill \ - --source-locations + [--source-locations ] \ + [--operation-name ] # 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 \ [--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 \ --drill \ @@ -4661,13 +4669,13 @@ azmcp resiliency drill run add-notes --service-group \ --drill-run \ --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 \ --drill \ --drill-run \ --source-locations \ - [--selected-resource-ids ] \ + [--selected-resource-ids ] \ [--auto-failover ] # Resume a failover drill run paused after fault injection @@ -4683,11 +4691,12 @@ azmcp resiliency drill run mark-complete --service-group \ --drill-run \ --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 \ --drill \ - --drill-run + --drill-run \ + [--selected-resource-ids ] # Get a resource (target) of a drill run, or list all resources of the run (omit --name) # ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index e9ead5508..73bc7fb17 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -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 in service group using subscription , region , resource group , automated built-in roles, and recovery plan | none | +| resiliency_drill_create | Create a zonal resilience drill named in service group using subscription , region , resource group , and automated built-in roles | none | | resiliency_drill_create | Create a regional resilience drill named in service group using subscription , region , and manual RBAC setup | none | | resiliency_drill_create | Create a resilience drill for service group | clarification-required | +| resiliency_drill_create | Create a zonal resilience drill in service group with Health Model monitoring for 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 from service group | none | | resiliency_drill_delete | Permanently remove drill in service group | none | | resiliency_drill_end | End resilience drill in 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 | none | | resiliency_drill_get | Get the details of resilience drill in service group | none | | resiliency_drill_update | Update resilience drill in service group to use manual RBAC setup | none | -| resiliency_drill_update | Associate recovery plan with resilience drill in service group | none | +| resiliency_drill_update | Switch resilience drill in service group to the AutomatedCustomRole RBAC setup mode | none | +| resiliency_drill_update | Change the Health Model monitored by resilience drill in service group to without changing its identity | none | +| resiliency_drill_update | Clear SLI monitoring on resilience drill in service group while preserving its other configuration | none | | resiliency_drill_update | Move the supporting resources of resilience drill in service group to subscription \ and region \ | none | | resiliency_drill_resource_get | List all drill resources for resilience drill in service group | none | | resiliency_drill_resource_get | List all drill targets for resilience drill in 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 in service group | none | | resiliency_drill_validate-for-execution | Validate resilience drill in service group for execution from source location | none | | resiliency_drill_validate-for-execution | Preflight resilience drill in service group to confirm it is ready to run from source locations | none | +| resiliency_drill_validate-for-execution | Validate resilience drill in service group for Reprotect without specifying source locations | none | +| resiliency_drill_validate-for-execution | Preflight resilience drill in service group using default validation from source location | none | | resiliency_drill_resource_add-or-update | Add resource to resilience drill in service group with a fault duration of minutes | none | | resiliency_drill_resource_add-or-update | Update or exclude the resources of resilience drill in service group | none | | resiliency_drill_start | Start resilience drill in 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 for drill in service group | none | | resiliency_drill_run_add-notes | Add the note \ to drill run for drill in service group | none | | resiliency_drill_run_failover | Start failover for drill run of drill in service group , using source location | none | -| resiliency_drill_run_failover | Fail over selected resources in drill run from physical zones , and automatically continue after fault injection | none | +| resiliency_drill_run_failover | Start failover for drill run of resilience drill in service group from physical zone and continue automatically to failover after fault injection | none | +| resiliency_drill_run_failover | Fail over drill run from physical zone and pause for manual input instead of automatic failover | none | +| resiliency_drill_run_failover | Fail over selected drill-resource ARM IDs in drill run of drill in service group from physical zones , and automatically continue after fault injection | none | | resiliency_drill_run_resume | Resume paused drill run for drill in service group and proceed from fault injection to failover | none | | resiliency_drill_run_mark-complete | Mark the FaultInjection stage of drill run for drill in service group as complete | none | | resiliency_drill_run_mark-complete | Complete the fault injection stage of drill run for drill in service group so the drill run can proceed | none | | resiliency_drill_run_reprotect | Reprotect failed-over resources in drill run for drill in service group | none | +| resiliency_drill_run_reprotect | Reprotect only recovery resources in drill run of resilience drill in service group | none | +| resiliency_drill_run_reprotect | Start reprotection for all qualified resources in drill run without specifying a resource selection | none | | resiliency_drill_run_resource_get | List all resources of drill run for drill in service group | none | | resiliency_drill_run_resource_get | Get resource from drill run for drill in service group | none | | resiliency_goal_assignment_get | List all resilience goal assignments in 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 in service group by removing recovery resource from its resource membership while retaining the recoveryplan and its other recovery resources | none | | resiliency_recoveryplan_resource_get | List all resources (members) of recoveryplan in service group | none | | resiliency_recoveryplan_resource_get | Get the recovery resource for recoveryplan in service group | none | -| resiliency_usageplan_create | Create a resilience usage plan with plan type Basic in resource group | none | -| resiliency_usageplan_create | Set up a Basic resilience usage plan named in resource group | none | -| resiliency_usageplan_create | Update resilience usage plan in resource group to use the Basic plan type | none | +| resiliency_usageplan_create | Create a resilience usage plan with plan type Standard in resource group | none | +| resiliency_usageplan_create | Set up a Standard resilience usage plan named in resource group | none | +| resiliency_usageplan_create | Update resilience usage plan in resource group to use the Standard plan type | none | | resiliency_usageplan_delete | Delete resilience usage plan from resource group | none | | resiliency_usageplan_delete | Use Azure Resiliency Management usage-plan delete to permanently remove the entire usage plan from resource group , not an enrollment | none | | resiliency_usageplan_delete | Remove the usage plan of service group | clarification-required | diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillActionValidation.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillActionValidation.cs index 03dd34df3..738e166f6 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillActionValidation.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillActionValidation.cs @@ -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? 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 s_modes = new(StringComparer.OrdinalIgnoreCase) { "Failover", diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillConfiguration.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillConfiguration.cs new file mode 100644 index 000000000..bdabb6706 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillConfiguration.cs @@ -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(goal, "goal-assignment-properties", null); + } + if (healthModel is not null) + { + properties.HealthModelMonitoringProperties = Read(healthModel, "health-model-monitoring-properties", "healthModelId"); + } + if (sli is not null) + { + properties.SliMonitoringProperties = Read(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(string json, string option, string? selection) where T : class + { + JsonElement value = Parse(json, option, selection, false); + return ModelReaderWriter.Read(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(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(StringComparer.OrdinalIgnoreCase); + var types = new HashSet(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."); + } + } +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillConfigurationPatch.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillConfigurationPatch.cs new file mode 100644 index 000000000..6c6baacfa --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillConfigurationPatch.cs @@ -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); + } + } +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillCreateCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillCreateCommand.cs index ebaea6e48..d48dbfb8e 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillCreateCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillCreateCommand.cs @@ -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 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 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 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) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillUpdateCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillUpdateCommand.cs index 70299e038..60f4491bc 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillUpdateCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillUpdateCommand.cs @@ -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 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 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 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 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) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillValidateForExecutionCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillValidateForExecutionCommand.cs index ca14fe6aa..3e3c607a4 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillValidateForExecutionCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/DrillValidateForExecutionCommand.cs @@ -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); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/Resources/DrillAddOrUpdateResourcesCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/Resources/DrillAddOrUpdateResourcesCommand.cs index bdf7a70ec..e8732a70c 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/Resources/DrillAddOrUpdateResourcesCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/Resources/DrillAddOrUpdateResourcesCommand.cs @@ -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 logger, IResilienceManagementService resilienceManagementService) : AuthenticatedCommand { - private static readonly ResourceType ServiceGroupResourceType = new("Microsoft.Management/serviceGroups"); private readonly ILogger _logger = logger; private readonly IResilienceManagementService _resilienceManagementService = resilienceManagementService; @@ -38,19 +35,16 @@ public sealed class DrillRunFailoverCommand(ILogger 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 '-az', 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 ExecuteAsync(CommandContext context, DrillRunFailoverOptions options, CancellationToken cancellationToken) @@ -133,24 +127,5 @@ public sealed class DrillRunFailoverCommand(ILogger 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); } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/Runs/DrillRunReprotectCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/Runs/DrillRunReprotectCommand.cs index cc8691c7e..62c64ff91 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/Runs/DrillRunReprotectCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/Runs/DrillRunReprotectCommand.cs @@ -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 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 ExecuteAsync(CommandContext context, DrillRunReprotectOptions options, CancellationToken cancellationToken) @@ -44,6 +45,7 @@ public sealed class DrillRunReprotectCommand(ILogger l options.ServiceGroup, options.Drill, options.DrillRun, + options.SelectedResourceIds, options.Tenant, cancellationToken); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanValidateForOperationCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanValidateForOperationCommand.cs index e4478c306..76126697e 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanValidateForOperationCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanValidateForOperationCommand.cs @@ -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; } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs index c8a1e5f12..2e6cd910a 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs @@ -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))] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillAddOrUpdateResourcesResult.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillAddOrUpdateResourcesResult.cs index 8c53956b8..8293142a1 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillAddOrUpdateResourcesResult.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillAddOrUpdateResourcesResult.cs @@ -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); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillExecutionOperation.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillExecutionOperation.cs new file mode 100644 index 000000000..cdb20d805 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillExecutionOperation.cs @@ -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 +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillInfo.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillInfo.cs index 9e2708561..1de8c9612 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillInfo.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillInfo.cs @@ -13,4 +13,5 @@ public sealed record DrillInfo( [property: JsonPropertyName("location")] string? Location = null, [property: JsonPropertyName("tags")] IReadOnlyDictionary? 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); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillResyncReadinessResult.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillResyncReadinessResult.cs index ce195be6e..0de3b0508 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillResyncReadinessResult.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillResyncReadinessResult.cs @@ -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); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillRunMarkCompleteResult.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillRunMarkCompleteResult.cs index 6dbc6ba9f..72494c385 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillRunMarkCompleteResult.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillRunMarkCompleteResult.cs @@ -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); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillValidateForExecutionResult.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillValidateForExecutionResult.cs index 1707ebfe8..f3c2bf6fe 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillValidateForExecutionResult.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillValidateForExecutionResult.cs @@ -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); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/GoalAssignmentInfo.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/GoalAssignmentInfo.cs index fdc9691b4..ad511e026 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/GoalAssignmentInfo.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/GoalAssignmentInfo.cs @@ -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, diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/GoalResourceInfo.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/GoalResourceInfo.cs index f7874bef8..a49b9d5ad 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/GoalResourceInfo.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/GoalResourceInfo.cs @@ -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? 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, diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/UsagePlanKind.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/UsagePlanKind.cs index 3e3edefab..6002e7fd5 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/UsagePlanKind.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/UsagePlanKind.cs @@ -5,6 +5,5 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Models; public enum UsagePlanKind { - Basic, Standard } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/DrillCreateOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/DrillCreateOption.cs index 6a3e4e53a..970610a63 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/DrillCreateOption.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/DrillCreateOption.cs @@ -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; } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/DrillUpdateOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/DrillUpdateOption.cs index 102f54f84..9df21a78e 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/DrillUpdateOption.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/DrillUpdateOption.cs @@ -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; } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/DrillValidateForExecutionOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/DrillValidateForExecutionOption.cs index 18bfe4e45..f448ffd10 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/DrillValidateForExecutionOption.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/DrillValidateForExecutionOption.cs @@ -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; } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/Resources/DrillAddOrUpdateResourcesOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/Resources/DrillAddOrUpdateResourcesOption.cs index 95ef0e6e2..914f0cf2c 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/Resources/DrillAddOrUpdateResourcesOption.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/Resources/DrillAddOrUpdateResourcesOption.cs @@ -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.")] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/Runs/DrillRunFailoverOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/Runs/DrillRunFailoverOption.cs index 040432899..acff70387 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/Runs/DrillRunFailoverOption.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/Runs/DrillRunFailoverOption.cs @@ -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)] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/Runs/DrillRunReprotectOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/Runs/DrillRunReprotectOption.cs index 1bd2c400a..51fce73f9 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/Runs/DrillRunReprotectOption.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/Runs/DrillRunReprotectOption.cs @@ -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; } } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/UsagePlans/UsagePlanCreateOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/UsagePlans/UsagePlanCreateOption.cs index d48314dd4..eef8d32b9 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/UsagePlans/UsagePlanCreateOption.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/UsagePlans/UsagePlanCreateOption.cs @@ -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)] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs index af4c76762..b947d465f 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs @@ -47,7 +47,7 @@ public interface IResilienceManagementService Task ValidateRecoveryPlanForReprotectAsync(string serviceGroup, string recoveryPlan, IReadOnlyList? selectedResourceIds = null, string? tenant = null, CancellationToken cancellationToken = default); - Task ValidateRecoveryPlanForOperationAsync(string serviceGroup, string recoveryPlan, RecoveryOperationNames operationName, string? tenant = null, CancellationToken cancellationToken = default); + Task ValidateRecoveryPlanForOperationAsync(string serviceGroup, string recoveryPlan, RecoveryOperationName operationName, string? tenant = null, CancellationToken cancellationToken = default); Task CheckRecoveryPlanReadinessAsync(string serviceGroup, string recoveryPlan, string? tenant = null, CancellationToken cancellationToken = default); @@ -73,7 +73,7 @@ public interface IResilienceManagementService Task CheckDrillResyncReadinessAsync(string serviceGroup, string drill, string? tenant = null, CancellationToken cancellationToken = default); - Task ValidateDrillForExecutionAsync(string serviceGroup, string drill, IEnumerable sourceLocations, string? tenant = null, CancellationToken cancellationToken = default); + Task ValidateDrillForExecutionAsync(string serviceGroup, string drill, IEnumerable? sourceLocations = null, DrillRunTask? operationName = null, string? tenant = null, CancellationToken cancellationToken = default); Task AddOrUpdateDrillResourcesAsync(string serviceGroup, string drill, AddOrUpdateResourcesContent content, string? tenant = null, CancellationToken cancellationToken = default); @@ -87,13 +87,13 @@ public interface IResilienceManagementService Task MarkDrillRunCompleteAsync(string serviceGroup, string drill, string drillRun, string stage, string? tenant = null, CancellationToken cancellationToken = default); - Task ReprotectDrillRunAsync(string serviceGroup, string drill, string drillRun, string? tenant = null, CancellationToken cancellationToken = default); + Task ReprotectDrillRunAsync(string serviceGroup, string drill, string drillRun, IReadOnlyList? selectedResourceIds = null, string? tenant = null, CancellationToken cancellationToken = default); Task EndDrillAsync(string serviceGroup, string drill, string attestation, string attestationNotes, string? tenant = null, CancellationToken cancellationToken = default); - Task UpdateDrillAsync(string serviceGroup, string drill, string? subscription = null, string? region = null, DrillRbacSetupMode? rbacSetupMode = null, string? recoveryPlan = null, string? tenant = null, CancellationToken cancellationToken = default); + Task UpdateDrillAsync(string serviceGroup, string drill, string? subscription = null, string? region = null, DrillRbacSetupMode? rbacSetupMode = null, DrillUpdateProperties? properties = null, string? tenant = null, CancellationToken cancellationToken = default); - Task 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 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); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs index 3f95f7eac..5b40ee9fd 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs @@ -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 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 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? 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 ValidateRecoveryPlanForOperationAsync(string serviceGroup, string recoveryPlan, RecoveryOperationNames operationName, string? tenant = null, CancellationToken cancellationToken = default) + public Task 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 ValidateRecoveryPlanForOperationCoreAsync(string serviceGroup, string recoveryPlan, RecoveryOperationNames operationName, string? tenant, CancellationToken cancellationToken) + private async Task 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 GetFailedTasks(IReadOnlyList? tasks) + private static List GetFailedTasks(IReadOnlyList? tasks) { var failedTasks = new List(); if (tasks is not null) @@ -1156,9 +1157,9 @@ public sealed class ResilienceManagementService(IAzureService azureService) return failedTasks; } - private static void AddFailedTasks(IReadOnlyList tasks, List failedTasks) + private static void AddFailedTasks(IReadOnlyList tasks, List 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 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 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 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 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 ValidateDrillForExecutionAsync( string serviceGroup, string drill, - IEnumerable sourceLocations, + IEnumerable? 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 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 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 ReprotectDrillRunAsync(string serviceGroup, string drill, string drillRun, string? tenant = null, CancellationToken cancellationToken = default) + public async Task ReprotectDrillRunAsync(string serviceGroup, string drill, string drillRun, IReadOnlyList? 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? selectedResourceIds) + { + var content = new DrillRunReprotectContent(); + foreach (string resourceId in selectedResourceIds ?? []) + { + content.ReprotectRequestSelectedResourceIds.Add(new ResourceIdentifier(resourceId)); + } + return content; + } + public async Task> 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 operation = await enrollments.CreateOrUpdateAsync(WaitUntil.Started, enrollment, enrollmentData, cancellationToken); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/DrillConfigurationTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/DrillConfigurationTests.cs new file mode 100644 index 000000000..d18791809 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/DrillConfigurationTests.cs @@ -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(() => + 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(() => 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(() => 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(() => 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(() => DrillConfiguration.Update(null, null, payload)); + } + + [Fact] + public void Update_RejectsClearingGoalAssignmentProperties() + { + Assert.Throws(() => 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()); + } +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/DrillCreateCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/DrillCreateCommandTests.cs index d64566483..6a12f84e1 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/DrillCreateCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/DrillCreateCommandTests.cs @@ -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(), null, Arg.Any()); } @@ -123,7 +126,7 @@ public sealed class DrillCreateCommandTests : CommandUnitTestsBase(), null, Arg.Any()); } @@ -143,7 +146,7 @@ public sealed class DrillCreateCommandTests : CommandUnitTestsBase(), Arg.Any(), Arg.Any(), - Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new RequestFailedException((int)status, "Sensitive provider details")); @@ -165,9 +168,39 @@ public sealed class DrillCreateCommandTests : CommandUnitTestsBase(), Arg.Any(), Arg.Any(), - Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) .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(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()); + } } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/DrillUpdateCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/DrillUpdateCommandTests.cs index 889386b40..3d14bb6d9 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/DrillUpdateCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/DrillUpdateCommandTests.cs @@ -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(), null, Arg.Any()); } @@ -79,7 +84,7 @@ public sealed class DrillUpdateCommandTests : CommandUnitTestsBase(), Arg.Any(), Arg.Any(), - Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new RequestFailedException((int)status, "Sensitive provider details")); @@ -99,9 +104,36 @@ public sealed class DrillUpdateCommandTests : CommandUnitTestsBase(), Arg.Any(), Arg.Any(), - Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) .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(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()); + } } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/DrillValidateForExecutionCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/DrillValidateForExecutionCommandTests.cs index 22dcefe09..e9eb87c78 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/DrillValidateForExecutionCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/DrillValidateForExecutionCommandTests.cs @@ -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>(locations => locations.SequenceEqual(new[] { "westus2-az1" })), + new DrillRunTask(operation), null, Arg.Any()) + .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()) + .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(), Arg.Any(), - Arg.Any>(), + Arg.Any?>(), + Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new DrillValidateForExecutionResult("operation1", false)); @@ -61,6 +124,7 @@ public class DrillValidateForExecutionCommandTests ServiceGroup, Drill, Arg.Is>(locations => locations.SequenceEqual(new[] { "eastus-az1", "westus-az2" })), + null, "tenant1", Arg.Any()) .Returns(new DrillValidateForExecutionResult("operation1", false)); @@ -103,6 +167,7 @@ public class DrillValidateForExecutionCommandTests ServiceGroup, Drill, Arg.Any>(), + Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception("Test error")); @@ -123,6 +188,7 @@ public class DrillValidateForExecutionCommandTests ServiceGroup, Drill, Arg.Any>(), + Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new RequestFailedException((int)HttpStatusCode.NotFound, "Not found")); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Resources/DrillAddOrUpdateResourcesCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Resources/DrillAddOrUpdateResourcesCommandTests.cs index 92c222ba9..1a816aeb6 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Resources/DrillAddOrUpdateResourcesCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Resources/DrillAddOrUpdateResourcesCommandTests.cs @@ -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(), null, Arg.Any()) + .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(content => + content.ResourceLists.ExcludeResources.Count == 1 && + content.ResourceLists.ExcludeResources[0].ToString() == excludedId), + null, + Arg.Any()); + } [Fact] public void Constructor_InitializesCommandCorrectly() diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Resources/DrillResourceGetCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Resources/DrillResourceGetCommandTests.cs index 4d4eeffef..efdbcef29 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Resources/DrillResourceGetCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Resources/DrillResourceGetCommandTests.cs @@ -85,4 +85,33 @@ public class DrillResourceGetCommandTests : CommandUnitTestsBase(), Arg.Any()) + .ThrowsAsync(error); + } + else + { + Service.ListDrillResourcesAsync(ServiceGroup, Drill, Arg.Any(), Arg.Any()) + .ThrowsAsync(error); + } + + var args = new List { "--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); + } } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Runs/DrillRunFailoverCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Runs/DrillRunFailoverCommandTests.cs index 6587afae9..49d889382 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Runs/DrillRunFailoverCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Runs/DrillRunFailoverCommandTests.cs @@ -18,9 +18,50 @@ public sealed class DrillRunFailoverCommandTests : CommandUnitTestsBase>(), null, false, null, Arg.Any()) + .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>(locations => locations.SequenceEqual(new[] { SourceLocation })), + null, false, null, Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_PreservesAutomaticFailoverFlag() + { + Service.FailoverDrillRunAsync(ServiceGroup, Drill, DrillRun, Arg.Any>(), null, true, null, Arg.Any()) + .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(), Arg.Any(), @@ -113,8 +159,10 @@ public sealed class DrillRunFailoverCommandTests : CommandUnitTestsBase()); } - [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>(locations => locations.SequenceEqual(new[] { SourceLocation, "westus2-az2" })), - Arg.Is>(resourceIds => resourceIds.SequenceEqual(new[] { SelectedResourceId })), + Arg.Is>(resourceIds => resourceIds.SequenceEqual(new[] { selectedResourceId })), true, null, Arg.Any()); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Runs/DrillRunReprotectCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Runs/DrillRunReprotectCommandTests.cs index 36261a72c..d05a7bef9 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Runs/DrillRunReprotectCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Runs/DrillRunReprotectCommandTests.cs @@ -19,6 +19,33 @@ public sealed class DrillRunReprotectCommandTests : CommandUnitTestsBase>(ids => ids.SequenceEqual(new[] { id })), null, Arg.Any()).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(), Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any(), Arg.Any()); } @@ -67,6 +95,7 @@ public sealed class DrillRunReprotectCommandTests : CommandUnitTestsBase()) .Returns(OperationId); @@ -84,6 +113,7 @@ public sealed class DrillRunReprotectCommandTests : CommandUnitTestsBase()); } @@ -99,6 +129,7 @@ public sealed class DrillRunReprotectCommandTests : CommandUnitTestsBase(), Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any(), Arg.Any()) .ThrowsAsync(new RequestFailedException((int)status, providerDetails)); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanValidateForOperationCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanValidateForOperationCommandTests.cs index 329c3108e..d48cfaf5e 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanValidateForOperationCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanValidateForOperationCommandTests.cs @@ -44,7 +44,7 @@ public sealed class RecoveryPlanValidateForOperationCommandTests : CommandUnitTe Service.ValidateRecoveryPlanForOperationAsync( "sg1", "plan1", - Arg.Is(value => value.ToString() == operationName), + Arg.Is(value => value.ToString() == operationName), null, Arg.Any()) .Returns(expected); @@ -86,7 +86,7 @@ public sealed class RecoveryPlanValidateForOperationCommandTests : CommandUnitTe Service.ValidateRecoveryPlanForOperationAsync( "sg1", "plan1", - Arg.Is(value => value == RecoveryOperationNames.Reprotect), + Arg.Is(value => value == RecoveryOperationName.Reprotect), null, Arg.Any()) .Returns(expected); @@ -114,7 +114,7 @@ public sealed class RecoveryPlanValidateForOperationCommandTests : CommandUnitTe Service.ValidateRecoveryPlanForOperationAsync( Arg.Any(), Arg.Any(), - Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new RequestFailedException((int)status, providerDetails)); @@ -136,7 +136,7 @@ public sealed class RecoveryPlanValidateForOperationCommandTests : CommandUnitTe Service.ValidateRecoveryPlanForOperationAsync( Arg.Any(), Arg.Any(), - Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new TimeoutException(internalDetails)); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs index 9c85731f1..4b1a73580 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs @@ -1,14 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// cspell:ignore LIFECYCLESERVICEGROUPNAME PLANLIFECYCLESERVICEGROUPNAME MARKCOMPLETESERVICEGROUP MARKCOMPLETEDRILLRUN MARKCOMPLETEDRILL WORKFLOWSERVICEGROUPNAME WORKFLOWRECOVERYPLANNAME WORKFLOWRECOVERYRESOURCEID +// cspell:ignore DELETEASSETRESOURCEGROUPNAME DRILLRESOURCEID HEALTHMODELID LIFECYCLEENROLLMENTNAME LIFECYCLESERVICEGROUPNAME CREATIONSERVICEGROUPNAME PLANLIFECYCLESERVICEGROUPNAME RECOVERYRESOURCEID slis WORKFLOWSERVICEGROUPNAME WORKFLOWRECOVERYPLANNAME WORKFLOWRECOVERYRESOURCEID DRILLSDELETEASSETRESOURCEGROUPNAME DRILLSDRILLNAME DRILLSDRILLRESOURCEID DRILLSDRILLRUNNAME DRILLSDRILLRUNRESOURCENAME DRILLSHEALTHMODELID DRILLSLIFECYCLESERVICEGROUPNAME DRILLSRECOVERYRESOURCEID DRILLSSERVICEGROUPNAME DRILLSSOURCELOCATION using System.Text.Json; using System.Text.Json.Nodes; +using Azure.Core; using Microsoft.Mcp.Tests; using Microsoft.Mcp.Tests.Attributes; using Microsoft.Mcp.Tests.Client; using Microsoft.Mcp.Tests.Client.Helpers; -using Microsoft.Mcp.Tests.Generated.Models; using Xunit; namespace Azure.Mcp.Tools.ResilienceManagement.Tests; @@ -21,84 +21,8 @@ public class ResilienceManagementCommandTests( ITestOutputHelper output, TestProxyFixture fixture, LiveServerFixture liveServerFixture) - : RecordedCommandTestsBase(output, fixture, liveServerFixture) + : ResilienceManagementRecordedTestsBase(output, fixture, liveServerFixture) { - // Preserve LRO Location paths during recording; replacing the entire header breaks polling playback. - public override List DisabledDefaultSanitizers => - [ - .. base.DisabledDefaultSanitizers, - "AZSDK2003" - ]; - - // Prepend the base sanitizers (e.g. WWW-Authenticate) then add tool-specific ones. - // Sanitize the required per-invocation operation-id request GUID for playback matching and the - // x-ms-operation-identifier response header, which contains the real tenant ID and object ID. - public override List 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)=)(?[^&]+)", - GroupForReplace = "value", - Value = "sanitized" - }), - new HeaderRegexSanitizer(new HeaderRegexSanitizerBody("Azure-AsyncOperation") - { - Regex = "([?&](?:t|c|s|h)=)(?[^&]+)", - GroupForReplace = "value", - Value = "sanitized" - }) - ]; - - public override List UriRegexSanitizers => - [ - .. base.UriRegexSanitizers, - new UriRegexSanitizer(new UriRegexSanitizerBody - { - Regex = "([?&](?:t|c|s|h)=)(?[^&]+)", - GroupForReplace = "value", - Value = "sanitized" - }), - new UriRegexSanitizer(new UriRegexSanitizerBody - { - Regex = @"resource[Gg]roups/([^?\\/]+)", - GroupForReplace = "1", - Value = "Sanitized" - }), - // The Drills backend requires a fresh operationId query parameter per invocation. Remove it from the - // sanitized URI so recordings made before the parameter was added still match current requests. - new UriRegexSanitizer(new UriRegexSanitizerBody - { - Regex = "&operationId=[^&]+", - Value = string.Empty - }) - ]; - - public override List BodyKeySanitizers => - [ - .. base.BodyKeySanitizers, - new BodyKeySanitizer(new BodyKeySanitizerBody("$..subscription") - { - Value = "Sanitized" - }), - new BodyKeySanitizer(new BodyKeySanitizerBody("$..healthModelId") - { - Value = "Sanitized" - }), - new BodyKeySanitizer(new BodyKeySanitizerBody("$..chaosExperimentId") - { - Value = "Sanitized" - }) - ]; - [Fact] public async Task Should_get_usage_plan() { @@ -163,8 +87,8 @@ public class ResilienceManagementCommandTests( [Fact] public async Task Should_list_drills() { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLNAME"); + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSSERVICEGROUPNAME"); + var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLSDRILLNAME"); var result = await CallToolAsync( "resiliency_drill_get", @@ -183,8 +107,8 @@ public class ResilienceManagementCommandTests( [Fact] public async Task Should_update_drill() { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLNAME"); + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSSERVICEGROUPNAME"); + var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLSDRILLNAME"); var result = await CallToolAsync( "resiliency_drill_update", @@ -206,96 +130,322 @@ public class ResilienceManagementCommandTests( [Fact] public async Task Should_create_drill() { - var resourceGroupName = RegisterOrRetrieveVariable("resourceGroupName", Settings.ResourceGroupName); - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var recoveryPlan = RegisterOrRetrieveDeploymentOutputVariable("recoveryPlanName", "RECOVERYPLANNAME"); - var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLNAME"); + var resourceGroup = RegisterOrRetrieveDeploymentOutputVariable("resourceGroupName", "DRILLSDELETEASSETRESOURCEGROUPNAME"); + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSLIFECYCLESERVICEGROUPNAME"); + var recoveryPlan = RegisterOrRetrieveVariable("recoveryPlanName", $"mcp-plan-{Guid.NewGuid().ToString("N")[..8]}"); + var drillName = RegisterOrRetrieveVariable("drillName", $"mcp-drill-{Guid.NewGuid().ToString("N")[..8]}"); + var location = RegisterOrRetrieveDeploymentOutputVariable("location", "LOCATION"); + List failures = []; + try + { + var planResult = await CallToolAsync("resiliency_recoveryplan_create", new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "recoveryplan", recoveryPlan }, + { "plan-type", "Zonal" }, + { "plan-description", "Drill creation lifecycle test." }, + { "identity-type", "SystemAssigned" } + }); + Assert.EndsWith($"/recoveryPlans/{recoveryPlan}", + planResult.AssertProperty("recoveryPlan").AssertProperty("id").GetString(), StringComparison.OrdinalIgnoreCase); - var result = await CallToolAsync( - "resiliency_drill_create", - new() + var result = await CallToolAsync("resiliency_drill_create", new() { { "tenant", Settings.TenantId }, { "service-group", serviceGroup }, { "drill", drillName }, { "subscription", Settings.SubscriptionId }, - { "region", "westus2" }, - { "resource-group", resourceGroupName }, + { "region", location }, + { "resource-group", resourceGroup }, { "drill-type", "Zonal" }, - { "rbac-setup-mode", "AutomatedBuiltinRoles" }, - { "recoveryplan", recoveryPlan } + { "rbac-setup-mode", "AutomatedBuiltinRoles" } }); + var drill = result.AssertProperty("drill"); + Assert.EndsWith($"/drills/{drillName}", drill.AssertProperty("id").GetString(), StringComparison.OrdinalIgnoreCase); + var properties = drill.AssertProperty("properties"); + Assert.Equal("Succeeded", properties.AssertProperty("provisioningState").GetString()); + Assert.Equal("Zonal", properties.AssertProperty("drillType").GetString()); + // recoveryPlanId is read-only: the service links the service group's Zonal recovery plan. + var recoveryPlanId = properties.AssertProperty("recoveryPlanProperties").AssertProperty("recoveryPlanId").GetString(); + Assert.EndsWith($"/recoveryPlans/{recoveryPlan}", recoveryPlanId, StringComparison.OrdinalIgnoreCase); + } + catch (Exception ex) + { + failures.Add(ex); + } - var drill = result.AssertProperty("drill"); - Assert.EndsWith(drillName, drill.AssertProperty("id").GetString(), StringComparison.OrdinalIgnoreCase); - var properties = drill.AssertProperty("properties"); - Assert.Equal("Succeeded", properties.AssertProperty("provisioningState").GetString()); - Assert.Equal("Zonal", properties.AssertProperty("drillType").GetString()); - var recoveryPlanId = properties.AssertProperty("recoveryPlanProperties").AssertProperty("recoveryPlanId").GetString(); - Assert.EndsWith($"/recoveryPlans/{recoveryPlan}", recoveryPlanId, StringComparison.OrdinalIgnoreCase); + foreach (var (tool, option, name, successProperty) in new[] + { + ("resiliency_drill_delete", "drill", drillName, "success"), + ("resiliency_recoveryplan_delete", "recoveryplan", recoveryPlan, "deleted") + }) + { + try + { + var deleted = await CallToolAsync(tool, new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { option, name } + }); + Assert.True(deleted.AssertProperty(successProperty).GetBoolean()); + } + catch (Exception cleanupFailure) + { + failures.Add(cleanupFailure); + } + } + if (failures.Count > 0) + { + throw new AggregateException("The drill creation scenario or its cleanup failed.", failures); + } } [Fact] public async Task Should_create_drill_without_recovery_plan() { - var resourceGroupName = RegisterOrRetrieveVariable("resourceGroupName", Settings.ResourceGroupName); - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - const string drillName = "mcp-drill-no-plan"; - - var result = await CallToolAsync( - "resiliency_drill_create", - new() + var resourceGroup = RegisterOrRetrieveDeploymentOutputVariable("resourceGroupName", "DRILLSDELETEASSETRESOURCEGROUPNAME"); + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSLIFECYCLESERVICEGROUPNAME"); + var location = RegisterOrRetrieveDeploymentOutputVariable("location", "LOCATION"); + var drillName = RegisterOrRetrieveVariable("drillName", $"mcp-drill-{Guid.NewGuid().ToString("N")[..8]}"); + Exception? failure = null; + try + { + var result = await CallToolAsync("resiliency_drill_create", new() { { "tenant", Settings.TenantId }, { "service-group", serviceGroup }, { "drill", drillName }, { "subscription", Settings.SubscriptionId }, - { "region", "westus2" }, - { "resource-group", resourceGroupName }, + { "region", location }, + { "resource-group", resourceGroup }, { "drill-type", "Zonal" }, { "rbac-setup-mode", "AutomatedBuiltinRoles" } }); + var drill = result.AssertProperty("drill"); + Assert.EndsWith($"/drills/{drillName}", drill.AssertProperty("id").GetString(), StringComparison.OrdinalIgnoreCase); + var properties = drill.AssertProperty("properties"); + Assert.Equal("Succeeded", properties.AssertProperty("provisioningState").GetString()); + Assert.Equal("Zonal", properties.AssertProperty("drillType").GetString()); + var recoveryPlanProperties = properties.AssertProperty("recoveryPlanProperties"); + Assert.Equal("SystemAssigned", recoveryPlanProperties.AssertProperty("identity").AssertProperty("type").GetString()); + Assert.True(!recoveryPlanProperties.TryGetProperty("recoveryPlanId", out var recoveryPlanId) || + recoveryPlanId.ValueKind == JsonValueKind.Null); + Assert.Equal("SystemAssigned", properties.AssertProperty("monitoringProperties").AssertProperty("identity").AssertProperty("type").GetString()); + } + catch (Exception ex) + { + failure = ex; + throw; + } + finally + { + try + { + var deleted = await CallToolAsync("resiliency_drill_delete", new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "drill", drillName } + }); + Assert.True(deleted.AssertProperty("success").GetBoolean()); + } + catch (Exception cleanupFailure) when (failure is not null) + { + throw new AggregateException("The creation scenario and its cleanup both failed.", failure, cleanupFailure); + } + } + } + [Fact] + public async Task Should_get_drill() + { + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSSERVICEGROUPNAME"); + var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLSDRILLNAME"); + var result = await CallToolAsync("resiliency_drill_get", new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "name", drillName } + }); var drill = result.AssertProperty("drill"); - Assert.EndsWith(drillName, drill.AssertProperty("id").GetString(), StringComparison.OrdinalIgnoreCase); - var properties = drill.AssertProperty("properties"); - Assert.Equal("Succeeded", properties.AssertProperty("provisioningState").GetString()); - Assert.Equal("Zonal", properties.AssertProperty("drillType").GetString()); + Assert.EndsWith($"/drills/{drillName}", drill.AssertProperty("id").GetString(), StringComparison.OrdinalIgnoreCase); + Assert.Equal("Zonal", drill.AssertProperty("properties").AssertProperty("drillType").GetString()); + } - var recoveryPlanProperties = properties.AssertProperty("recoveryPlanProperties"); - Assert.Equal("SystemAssigned", recoveryPlanProperties.AssertProperty("identity").AssertProperty("type").GetString()); - Assert.True( - !recoveryPlanProperties.TryGetProperty("recoveryPlanId", out var recoveryPlanId) || - recoveryPlanId.ValueKind == JsonValueKind.Null); + [Fact] + public async Task Should_update_and_clear_drill_health_monitoring() + { + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSSERVICEGROUPNAME"); + var drill = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLSDRILLNAME"); + var healthModelId = RegisterOrRetrieveDeploymentOutputVariable("healthModelId", "DRILLSHEALTHMODELID"); + var getParameters = new Dictionary + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "name", drill } + }; + var baseline = (await CallToolAsync("resiliency_drill_get", getParameters)).AssertProperty("drill").AssertProperty("properties"); + Assert.Equal("Succeeded", baseline.AssertProperty("provisioningState").GetString()); + Assert.Equal("NotRunning", baseline.AssertProperty("executionState").GetString()); + var originalConfiguration = GetWritableDrillConfiguration(baseline, "healthModelMonitoringProperties", "healthModelId"); + var configuration = new JsonObject + { + ["identity"] = new JsonObject { ["type"] = "SystemAssigned" }, + ["healthModelId"] = healthModelId + }; + var parameters = new Dictionary + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "drill", drill }, + { "health-model-monitoring-properties", configuration.ToJsonString() } + }; + Exception? failure = null; + try + { + var configured = await CallToolAsync("resiliency_drill_update", parameters); + var properties = configured.AssertProperty("drill").AssertProperty("properties"); + Assert.Equal("Succeeded", properties.AssertProperty("provisioningState").GetString()); + AssertHealthModelConfiguration(properties, healthModelId); + var readBack = await CallToolAsync("resiliency_drill_get", getParameters); + AssertHealthModelConfiguration(readBack.AssertProperty("drill").AssertProperty("properties"), healthModelId); - var monitoringProperties = properties.AssertProperty("monitoringProperties"); - Assert.Equal("SystemAssigned", monitoringProperties.AssertProperty("identity").AssertProperty("type").GetString()); + configuration.Remove("identity"); + parameters["health-model-monitoring-properties"] = configuration.ToJsonString(); + var patched = await CallToolAsync("resiliency_drill_update", parameters); + AssertHealthModelConfiguration(patched.AssertProperty("drill").AssertProperty("properties"), healthModelId); + readBack = await CallToolAsync("resiliency_drill_get", getParameters); + AssertHealthModelConfiguration(readBack.AssertProperty("drill").AssertProperty("properties"), healthModelId); + + parameters["health-model-monitoring-properties"] = "null"; + var cleared = await CallToolAsync("resiliency_drill_update", parameters); + properties = cleared.AssertProperty("drill").AssertProperty("properties"); + Assert.True(!properties.TryGetProperty("healthModelMonitoringProperties", out var health) || health.ValueKind == JsonValueKind.Null); + readBack = await CallToolAsync("resiliency_drill_get", getParameters); + properties = readBack.AssertProperty("drill").AssertProperty("properties"); + Assert.True(!properties.TryGetProperty("healthModelMonitoringProperties", out health) || health.ValueKind == JsonValueKind.Null); + } + catch (Exception ex) + { + failure = ex; + throw; + } + finally + { + try + { + parameters["health-model-monitoring-properties"] = originalConfiguration ?? "null"; + var restored = await CallToolAsync("resiliency_drill_update", parameters); + Assert.Equal("Succeeded", restored.AssertProperty("drill").AssertProperty("properties").AssertProperty("provisioningState").GetString()); + var readBack = await CallToolAsync("resiliency_drill_get", getParameters); + var properties = readBack.AssertProperty("drill").AssertProperty("properties"); + Assert.Equal("NotRunning", properties.AssertProperty("executionState").GetString()); + var restoredConfiguration = GetWritableDrillConfiguration(properties, "healthModelMonitoringProperties", "healthModelId"); + Assert.True(JsonNode.DeepEquals( + JsonNode.Parse(originalConfiguration ?? "null"), + JsonNode.Parse(restoredConfiguration ?? "null")), + "The original Health Model monitoring configuration was not restored."); + } + catch (Exception cleanupFailure) when (failure is not null) + { + throw new AggregateException("The monitoring scenario and its restoration both failed.", failure, cleanupFailure); + } + } + } + + private static void AssertHealthModelConfiguration(JsonElement properties, string healthModelId) + { + var health = properties.AssertProperty("healthModelMonitoringProperties"); + Assert.Equal("SystemAssigned", health.AssertProperty("identity").AssertProperty("type").GetString()); + Assert.Equal(healthModelId, health.AssertProperty("healthModelId").GetString(), ignoreCase: true); + } + + private static string? GetWritableDrillConfiguration(JsonElement properties, string propertyName, string? selection) + { + if (!properties.TryGetProperty(propertyName, out var configuration) || configuration.ValueKind == JsonValueKind.Null) + { + return null; + } + var identity = configuration.AssertProperty("identity"); + var writableIdentity = new JsonObject { ["type"] = identity.AssertProperty("type").GetString() }; + if (identity.TryGetProperty("userAssignedIdentity", out var userIdentity) && userIdentity.ValueKind == JsonValueKind.String) + { + writableIdentity["userAssignedIdentity"] = userIdentity.GetString(); + } + var writableConfiguration = new JsonObject { ["identity"] = writableIdentity }; + if (selection is not null) + { + writableConfiguration[selection] = JsonNode.Parse(configuration.AssertProperty(selection).GetRawText()); + } + return writableConfiguration.ToJsonString(); } [Fact] public async Task Should_delete_drill() { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "LIFECYCLESERVICEGROUPNAME"); - var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DELETEDRILLNAME"); + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSLIFECYCLESERVICEGROUPNAME"); + var drillName = RegisterOrRetrieveVariable("drillName", $"mcp-delete-{Guid.NewGuid().ToString("N")[..8]}"); + var assetGroup = TestMode == Microsoft.Mcp.Tests.Helpers.TestMode.Playback || + Settings.DeploymentOutputs.ContainsKey("DELETEASSETRESOURCEGROUPNAME") + ? RegisterOrRetrieveDeploymentOutputVariable("deleteAssetGroup", "DRILLSDELETEASSETRESOURCEGROUPNAME") + : RegisterOrRetrieveVariable("deleteAssetGroup", Settings.ResourceGroupName); + var location = RegisterOrRetrieveDeploymentOutputVariable("location", "LOCATION"); + var created = await CallToolAsync("resiliency_drill_create", new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "drill", drillName }, + { "subscription", Settings.SubscriptionId }, + { "region", location }, + { "resource-group", assetGroup }, + { "drill-type", "Zonal" }, + { "rbac-setup-mode", "AutomatedBuiltinRoles" } + }); + Assert.Equal("Succeeded", created.AssertProperty("drill").AssertProperty("properties").AssertProperty("provisioningState").GetString()); var result = await CallToolAsync( "resiliency_drill_delete", new() { + { "tenant", Settings.TenantId }, { "service-group", serviceGroup }, { "drill", drillName } }); Assert.True(result.AssertProperty("success").GetBoolean()); + var missing = await CallToolAsync("resiliency_drill_get", new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "name", drillName } + }, resultProcessor: element => element); + Assert.Equal(404, missing.AssertProperty("status").GetInt32()); } [Fact] public async Task Should_check_drill_resync_readiness() { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLNAME"); + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSSERVICEGROUPNAME"); + var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLSDRILLNAME"); + await ResyncDrillAndWaitAsync(serviceGroup, drillName); + } - var result = await CallToolAsync( + private async Task ResyncDrillAndWaitAsync(string serviceGroup, string drillName) + { + var getParameters = new Dictionary + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "name", drillName } + }; + var before = await CallToolAsync("resiliency_drill_get", getParameters); + var beforeProperties = before.AssertProperty("drill").AssertProperty("properties"); + var previousCheck = beforeProperties.TryGetProperty("lastResyncReadinessCheckTime", out var previous) + ? previous.GetString() : null; + + var result = await CallToolWithConflictRetryAsync( "resiliency_drill_check-resync-readiness", new() { @@ -306,55 +456,66 @@ public class ResilienceManagementCommandTests( var readiness = result.AssertProperty("readiness"); Assert.False(string.IsNullOrEmpty(readiness.AssertProperty("operationId").GetString())); + for (int attempt = 0; attempt < 60; attempt++) + { + var current = await CallToolAsync("resiliency_drill_get", getParameters); + var properties = current.AssertProperty("drill").AssertProperty("properties"); + if (properties.TryGetProperty("lastResyncReadinessCheckTime", out var checkedAt) && + checkedAt.ValueKind == JsonValueKind.String && + checkedAt.GetString() != previousCheck) + { + return; + } + await Task.Delay(PollInterval(10000), TestContext.Current.CancellationToken); + } + Assert.Fail("The drill resync/readiness timestamp did not advance."); } - [Fact] - public async Task Should_validate_drill_for_execution() - { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLNAME"); + [Theory] + [InlineData("Failover", true)] + [InlineData("Reprotect", true)] + [InlineData("Reprotect", false)] + [InlineData("FailoverReverse", true)] + [InlineData("ReprotectReverse", true)] + public Task Should_validate_drill_for_execution(string operation, bool selectSource) => + ValidateDrillForExecutionAsync(operation, selectSource); - var result = await CallToolAsync( - "resiliency_drill_validate-for-execution", - new() + [Fact] + public Task Should_validate_drill_for_execution_with_default_operation() => + ValidateDrillForExecutionAsync(null, selectSource: true); + + private async Task ValidateDrillForExecutionAsync(string? operation, bool selectSource) + { + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSSERVICEGROUPNAME"); + var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLSDRILLNAME"); + var sourceLocation = RegisterOrRetrieveDeploymentOutputVariable("sourceLocation", "DRILLSSOURCELOCATION"); + + var parameters = new Dictionary { { "tenant", Settings.TenantId }, { "service-group", serviceGroup }, - { "drill", drillName }, - { "source-locations", new[] { "westus2-az1" } } - }); + { "drill", drillName } + }; + if (operation is not null) + { + parameters["operation-name"] = operation; + } + if (selectSource) + { + parameters["source-locations"] = new[] { sourceLocation }; + } + var result = await CallToolWithConflictRetryAsync("resiliency_drill_validate-for-execution", parameters); var validation = result.AssertProperty("validation"); Assert.False(string.IsNullOrEmpty(validation.AssertProperty("operationId").GetString())); } - [Fact] - public async Task Should_mark_drill_run_stage_complete() - { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("markCompleteServiceGroup", "MARKCOMPLETESERVICEGROUP"); - var drillName = RegisterOrRetrieveDeploymentOutputVariable("markCompleteDrill", "MARKCOMPLETEDRILL"); - var drillRun = RegisterOrRetrieveDeploymentOutputVariable("markCompleteDrillRun", "MARKCOMPLETEDRILLRUN"); - - var result = await CallToolAsync( - "resiliency_drill_run_mark-complete", - new() - { - { "tenant", Settings.TenantId }, - { "service-group", serviceGroup }, - { "drill", drillName }, - { "drill-run", drillRun }, - { "stage", "FaultInjection" } - }); - - var markComplete = result.AssertProperty("result"); - Assert.False(string.IsNullOrEmpty(markComplete.AssertProperty("operationId").GetString())); - } - [Fact] public async Task Should_list_drill_resources() { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLNAME"); + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSSERVICEGROUPNAME"); + var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLSDRILLNAME"); + var drillResourceId = RegisterOrRetrieveDeploymentOutputVariable("drillResourceId", "DRILLSDRILLRESOURCEID"); var result = await CallToolAsync( "resiliency_drill_resource_get", @@ -364,28 +525,19 @@ public class ResilienceManagementCommandTests( { "drill", drillName } }); - Assert.Equal(JsonValueKind.Array, result.AssertProperty("drillResources").ValueKind); + var resources = result.AssertProperty("drillResources"); + Assert.Equal(JsonValueKind.Array, resources.ValueKind); + Assert.Contains(resources.EnumerateArray(), resource => + string.Equals(drillResourceId, resource.AssertProperty("id").GetString(), StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task Should_get_drill_resource() { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLNAME"); - - var listResult = await CallToolAsync( - "resiliency_drill_resource_get", - new() - { - { "service-group", serviceGroup }, - { "drill", drillName } - }); - - var drillResources = listResult.AssertProperty("drillResources"); - Assert.NotEqual(0, drillResources.GetArrayLength()); - var drillResourceName = RegisterOrRetrieveVariable( - "drillResourceName", - drillResources.EnumerateArray().First().AssertProperty("name").GetString()!); + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSSERVICEGROUPNAME"); + var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLSDRILLNAME"); + var drillResourceId = RegisterOrRetrieveDeploymentOutputVariable("drillResourceId", "DRILLSDRILLRESOURCEID"); + var drillResourceName = new ResourceIdentifier(drillResourceId).Name; var result = await CallToolAsync( "resiliency_drill_resource_get", @@ -396,41 +548,111 @@ public class ResilienceManagementCommandTests( { "name", drillResourceName } }); - Assert.Equal(JsonValueKind.Object, result.AssertProperty("drillResource").ValueKind); + var resource = result.AssertProperty("drillResource"); + Assert.Equal(drillResourceId, resource.AssertProperty("id").GetString(), ignoreCase: true); + Assert.Contains(resource.AssertProperty("properties").AssertProperty("drillType").GetString(), new[] { "Zonal", "Regional" }); } - [Fact] - [CustomMatcher(compareBody: false)] - public async Task Should_start_and_end_drill() + [Theory] + [InlineData("TestFailover")] + [InlineData("Failover")] + public async Task Should_start_and_end_drill(string mode) { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLNAME"); - bool startAccepted = false; + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSSERVICEGROUPNAME"); + var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLSDRILLNAME"); + await WithDrillRunAsync(serviceGroup, drillName, run => + { + Assert.True(Guid.TryParse(run, out _)); + return Task.CompletedTask; + }, mode); + } + + private async Task WithDrillRunAsync(string serviceGroup, string drill, Func scenario, string mode = "Failover") + { + var existing = await GetDrillRunNamesAsync(serviceGroup, drill); + bool started = false; + Exception? failure = null; + try + { + var start = await StartDrillAsync(serviceGroup, drill, mode); + started = true; + Assert.True(Guid.TryParse(start.AssertProperty("operationId").GetString(), out _)); + Assert.Equal("Accepted", start.AssertProperty("status").GetString()); + await WaitForDrillExecutionStateAsync(serviceGroup, drill, "Running"); + string? run = null; + for (int attempt = 0; attempt < 120 && run is null; attempt++) + { + var created = (await GetDrillRunNamesAsync(serviceGroup, drill)).Except(existing).ToArray(); + Assert.True(created.Length <= 1, "Another execution is modifying the isolated drill fixture."); + run = created.SingleOrDefault(); + if (run is null) + { + await Task.Delay(PollInterval(10000), TestContext.Current.CancellationToken); + } + } + Assert.NotNull(run); + await scenario(run); + } + catch (Exception ex) + { + failure = ex; + } try { - var startResult = await StartDrillAsync(serviceGroup, drillName); - startAccepted = true; - - Assert.False(string.IsNullOrEmpty(startResult.AssertProperty("operationId").GetString())); - Assert.Equal("Accepted", startResult.AssertProperty("status").GetString()); - - var endResult = await EndDrillAsync(serviceGroup, drillName); - startAccepted = false; - - Assert.False(string.IsNullOrEmpty(endResult.AssertProperty("operationId").GetString())); - Assert.Equal("Accepted", endResult.AssertProperty("status").GetString()); - } - finally - { - if (startAccepted) + if (started) { - await EndDrillAsync(serviceGroup, drillName); + var end = await EndDrillAsync(serviceGroup, drill, failure is null ? "Success" : "Failed"); + Assert.True(Guid.TryParse(end.AssertProperty("operationId").GetString(), out _)); + Assert.Equal("Accepted", end.AssertProperty("status").GetString()); + await WaitForDrillExecutionStateAsync(serviceGroup, drill, "NotRunning"); } } + catch (Exception cleanupFailure) when (failure is not null) + { + throw new AggregateException("The drill scenario and its cleanup both failed.", failure, cleanupFailure); + } + if (failure is not null) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw(); + } } - private async Task StartDrillAsync(string serviceGroup, string drillName) + private async Task GetDrillRunNamesAsync(string serviceGroup, string drill) + { + var result = await CallToolAsync("resiliency_drill_run_get", new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "drill", drill } + }); + // Display names are sanitized; ARM IDs retain each run's identity in playback. + return result.AssertProperty("drillRuns").EnumerateArray() + .Select(run => new ResourceIdentifier(run.AssertProperty("id").GetString()!).Name).ToArray(); + } + + private async Task WaitForDrillExecutionStateAsync(string serviceGroup, string drill, string expected) + { + for (int attempt = 0; attempt < 90; attempt++) + { + var result = await CallToolAsync("resiliency_drill_get", new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "name", drill } + }); + var properties = result.AssertProperty("drill").AssertProperty("properties"); + Assert.NotEqual("Failed", properties.AssertProperty("provisioningState").GetString()); + if (properties.AssertProperty("executionState").GetString() == expected) + { + return; + } + await Task.Delay(PollInterval(10000), TestContext.Current.CancellationToken); + } + Assert.Fail($"Drill execution state did not become {expected}."); + } + + private async Task StartDrillAsync(string serviceGroup, string drillName, string mode) { const int maxAttempts = 36; @@ -442,7 +664,7 @@ public class ResilienceManagementCommandTests( { { "service-group", serviceGroup }, { "drill", drillName }, - { "mode", "TestFailover" } + { "mode", mode } }, resultProcessor: element => element); Assert.True(response.HasValue); @@ -461,7 +683,7 @@ public class ResilienceManagementCommandTests( return default; } - private async Task EndDrillAsync(string serviceGroup, string drillName) + private async Task EndDrillAsync(string serviceGroup, string drillName, string attestation = "Success") { const int maxAttempts = 36; @@ -473,7 +695,7 @@ public class ResilienceManagementCommandTests( { { "service-group", serviceGroup }, { "drill", drillName }, - { "attestation", "Success" }, + { "attestation", attestation }, { "attestation-notes", "Azure MCP recorded lifecycle test completed." } }, resultProcessor: element => element); @@ -496,9 +718,9 @@ public class ResilienceManagementCommandTests( [Fact] public async Task Should_list_drill_runs() { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var drill = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLNAME"); - var drillRun = RegisterOrRetrieveDeploymentOutputVariable("drillRunName", "DRILLRUNNAME"); + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSSERVICEGROUPNAME"); + var drill = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLSDRILLNAME"); + var drillRun = RegisterOrRetrieveDeploymentOutputVariable("drillRunName", "DRILLSDRILLRUNNAME"); var result = await CallToolAsync( "resiliency_drill_run_get", @@ -519,9 +741,9 @@ public class ResilienceManagementCommandTests( [Fact] public async Task Should_get_drill_run() { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var drill = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLNAME"); - var drillRun = RegisterOrRetrieveDeploymentOutputVariable("drillRunName", "DRILLRUNNAME"); + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSSERVICEGROUPNAME"); + var drill = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLSDRILLNAME"); + var drillRun = RegisterOrRetrieveDeploymentOutputVariable("drillRunName", "DRILLSDRILLRUNNAME"); var result = await CallToolAsync( "resiliency_drill_run_get", @@ -541,153 +763,253 @@ public class ResilienceManagementCommandTests( public async Task Should_add_notes_to_drill_run() { const string note = "Recorded MCP add-notes validation."; - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var drill = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLNAME"); - var drillRun = RegisterOrRetrieveDeploymentOutputVariable("drillRunName", "DRILLRUNNAME"); - - var result = await CallToolAsync( - "resiliency_drill_run_add-notes", - new() - { + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSSERVICEGROUPNAME"); + var drill = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLSDRILLNAME"); + await WithDrillRunAsync(serviceGroup, drill, async drillRun => + { + var result = await CallToolAsync( + "resiliency_drill_run_add-notes", + new() + { { "tenant", Settings.TenantId }, { "service-group", serviceGroup }, { "drill", drill }, { "drill-run", drillRun }, { "notes", note } - }); + }); - Assert.True(result.AssertProperty("accepted").GetBoolean()); - Assert.Equal(drillRun, result.AssertProperty("drillRun").GetString()); - Assert.True(Guid.TryParse(result.AssertProperty("operationId").GetString(), out _)); - await WaitForDrillRunNoteAsync(serviceGroup, drill, drillRun, note); + Assert.True(result.AssertProperty("accepted").GetBoolean()); + Assert.Equal(drillRun, result.AssertProperty("drillRun").GetString()); + Assert.True(Guid.TryParse(result.AssertProperty("operationId").GetString(), out _)); + await WaitForDrillRunNoteAsync(serviceGroup, drill, drillRun, note); + }); } [Fact] - public async Task Should_start_resume_and_reprotect_drill_run_failover() - { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var drill = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLNAME"); - var drillRun = RegisterOrRetrieveDeploymentOutputVariable("drillRunName", "DRILLRUNNAME"); - var sourceLocation = RegisterOrRetrieveDeploymentOutputVariable("drillRunSourceLocation", "DRILLRUNSOURCELOCATION"); + public Task Should_mark_drill_run_stage_complete() + => RunDrillFailoverLifecycleAsync(selectResources: false, autoFailover: false, completeReprotection: false); - var result = await CallToolAsync( - "resiliency_drill_run_failover", - new() + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + public Task Should_start_resume_and_reprotect_drill_run_failover(bool selectResources, bool autoFailover) + => RunDrillFailoverLifecycleAsync(selectResources, autoFailover); + + // The GA service completes fault injection but never starts the automatic failover stage, so no recording exists. + [Fact] + [LiveTestOnly] + public Task Should_start_resume_and_reprotect_drill_run_with_auto_failover() + => RunDrillFailoverLifecycleAsync(selectResources: true, autoFailover: true); + + private async Task RunDrillFailoverLifecycleAsync(bool selectResources, bool autoFailover, bool completeReprotection = true) + { + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSSERVICEGROUPNAME"); + var drill = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLSDRILLNAME"); + var sourceLocation = RegisterOrRetrieveDeploymentOutputVariable("drillRunSourceLocation", "DRILLSSOURCELOCATION"); + var recoveryResourceId = RegisterOrRetrieveDeploymentOutputVariable("recoveryResourceId", "DRILLSRECOVERYRESOURCEID"); + var drillResourceId = RegisterOrRetrieveDeploymentOutputVariable("drillResourceId", "DRILLSDRILLRESOURCEID"); + await WithDrillRunAsync(serviceGroup, drill, async drillRun => + { + var failoverParameters = new Dictionary { { "tenant", Settings.TenantId }, { "service-group", serviceGroup }, { "drill", drill }, { "drill-run", drillRun }, { "source-locations", new[] { sourceLocation } }, - { "auto-failover", false } - }); - - Assert.True(result.AssertProperty("accepted").GetBoolean()); - Assert.Equal(drillRun, result.AssertProperty("drillRun").GetString()); - Assert.True(Guid.TryParse(result.AssertProperty("operationId").GetString(), out _)); - - // FaultInjection only offers MarkAsComplete once it finishes; Failover does not unlock its own - // Start verb until that stage is explicitly marked complete. - await WaitForStageVerbAsync(serviceGroup, drill, drillRun, "FaultInjection", "MarkAsComplete"); - - result = await CallToolWithConflictRetryAsync( - "resiliency_drill_run_mark-complete", - new() + { "auto-failover", autoFailover } + }; + if (selectResources) { - { "tenant", Settings.TenantId }, - { "service-group", serviceGroup }, - { "drill", drill }, - { "drill-run", drillRun }, - { "stage", "FaultInjection" } - }); + failoverParameters["selected-resource-ids"] = new[] { drillResourceId }; + } + var result = await CallToolWithConflictRetryAsync("resiliency_drill_run_failover", failoverParameters); + Assert.True(result.AssertProperty("accepted").GetBoolean()); + Assert.Equal(drillRun, result.AssertProperty("drillRun").GetString()); + Assert.True(Guid.TryParse(result.AssertProperty("operationId").GetString(), out _)); - Assert.False(string.IsNullOrEmpty(result.AssertProperty("result").AssertProperty("operationId").GetString())); - - await WaitForStageVerbAsync(serviceGroup, drill, drillRun, "Failover", "Start"); - - result = await CallToolAsync( - "resiliency_drill_run_resume", - new() + // Automatic failover can continue past fault injection without pausing for manual stages. + var manualStage = autoFailover + ? await WaitForAutomaticFailoverProgressAsync(serviceGroup, drill, drillRun) + : "FaultInjection"; + if (manualStage == "FaultInjection") { - { "tenant", Settings.TenantId }, - { "service-group", serviceGroup }, - { "drill", drill }, - { "drill-run", drillRun } - }); + await WaitForStageVerbAsync(serviceGroup, drill, drillRun, "FaultInjection", "MarkAsComplete"); + result = await CallToolWithConflictRetryAsync("resiliency_drill_run_mark-complete", new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "drill", drill }, + { "drill-run", drillRun }, + { "stage", "FaultInjection" } + }, maxAttempts: 36); + Assert.True(Guid.TryParse(result.AssertProperty("result").AssertProperty("operationId").GetString(), out _)); + await WaitForStageVerbAsync(serviceGroup, drill, drillRun, "Failover", "Start"); + } + if (manualStage is not null) + { + result = await CallToolWithConflictRetryAsync("resiliency_drill_run_resume", new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "drill", drill }, + { "drill-run", drillRun } + }, maxAttempts: 36); + Assert.True(result.AssertProperty("accepted").GetBoolean()); + Assert.Equal(drillRun, result.AssertProperty("drillRun").GetString()); + Assert.True(Guid.TryParse(result.AssertProperty("operationId").GetString(), out _)); + } - Assert.True(result.AssertProperty("accepted").GetBoolean()); - Assert.Equal(drillRun, result.AssertProperty("drillRun").GetString()); - Assert.True(Guid.TryParse(result.AssertProperty("operationId").GetString(), out _)); - - // Same lifecycle rule applies to Failover -> Reprotect: mark Failover complete explicitly. - await WaitForStageVerbAsync(serviceGroup, drill, drillRun, "Failover", "MarkAsComplete"); - - result = await CallToolWithConflictRetryAsync( - "resiliency_drill_run_mark-complete", - new() + await WaitForStageVerbAsync(serviceGroup, drill, drillRun, "Failover", "MarkAsComplete"); + result = await CallToolWithConflictRetryAsync("resiliency_drill_run_mark-complete", new() { { "tenant", Settings.TenantId }, { "service-group", serviceGroup }, { "drill", drill }, { "drill-run", drillRun }, { "stage", "Failover" } - }); + }, maxAttempts: 36); + Assert.True(Guid.TryParse(result.AssertProperty("result").AssertProperty("operationId").GetString(), out _)); + await WaitForStageVerbAsync(serviceGroup, drill, drillRun, "Reprotect", "Start", "Retry"); + if (!completeReprotection) + { + return; + } - Assert.False(string.IsNullOrEmpty(result.AssertProperty("result").AssertProperty("operationId").GetString())); + var reprotectParameters = new Dictionary + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "drill", drill }, + { "drill-run", drillRun } + }; + if (selectResources) + { + reprotectParameters["selected-resource-ids"] = new[] { recoveryResourceId }; + } + result = await CallToolWithConflictRetryAsync("resiliency_drill_run_reprotect", reprotectParameters, maxAttempts: 36); + Assert.True(result.AssertProperty("accepted").GetBoolean()); + Assert.Equal(drillRun, result.AssertProperty("drillRun").GetString()); + Assert.True(Guid.TryParse(result.AssertProperty("operationId").GetString(), out _)); + await WaitForStageVerbAsync(serviceGroup, drill, drillRun, "Reprotect", "MarkAsComplete"); - await WaitForStageVerbAsync(serviceGroup, drill, drillRun, "Reprotect", "Start", "Retry"); - - result = await CallToolAsync( - "resiliency_drill_run_reprotect", - new() + var resources = await CallToolAsync("resiliency_drill_run_resource_get", new() { { "tenant", Settings.TenantId }, { "service-group", serviceGroup }, { "drill", drill }, { "drill-run", drillRun } }); - - Assert.True(result.AssertProperty("accepted").GetBoolean()); - Assert.Equal(drillRun, result.AssertProperty("drillRun").GetString()); - Assert.True(Guid.TryParse(result.AssertProperty("operationId").GetString(), out _)); - } - - /// - /// Polls resiliency_drill_run_get until the given drill run stage advertises one of the expected verbs, - /// failing the test if it never does within the timeout. - /// - private async Task WaitForStageVerbAsync(string serviceGroup, string drill, string drillRun, string stageName, params string[] expectedVerbs) - { - bool reachedExpectedVerb = false; - for (int attempt = 0; attempt < 60 && !reachedExpectedVerb; attempt++) - { - var getResult = await CallToolAsync( - "resiliency_drill_run_get", - new() + var runResources = resources.AssertProperty("drillRunResources").EnumerateArray().ToArray(); + Assert.NotEmpty(runResources); + foreach (var resource in runResources) + { + var resourceName = new ResourceIdentifier(resource.AssertProperty("id").GetString()!).Name; + var detail = await CallToolAsync("resiliency_drill_run_resource_get", new() { { "tenant", Settings.TenantId }, { "service-group", serviceGroup }, { "drill", drill }, - { "name", drillRun } + { "drill-run", drillRun }, + { "name", resourceName } }); - - reachedExpectedVerb = getResult - .AssertProperty("drillRun") - .AssertProperty("properties") - .AssertProperty("supportedVerbsForStage") - .EnumerateArray() - .Any(stage => - stage.AssertProperty("drillRunStage").GetString() == stageName && - stage.AssertProperty("supportedVerbs").EnumerateArray().Any(verb => - expectedVerbs.Contains(verb.GetString()))); - - if (!reachedExpectedVerb && TestMode != Microsoft.Mcp.Tests.Helpers.TestMode.Playback) + AssertNoFailedDrillTasks(detail.AssertProperty("drillRunResource").AssertProperty("properties")); + } + result = await CallToolWithConflictRetryAsync("resiliency_drill_run_mark-complete", new() { - await Task.Delay(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "drill", drill }, + { "drill-run", drillRun }, + { "stage", "Reprotect" } + }, maxAttempts: 36); + Assert.True(Guid.TryParse(result.AssertProperty("result").AssertProperty("operationId").GetString(), out _)); + }); + } + + private async Task WaitForAutomaticFailoverProgressAsync(string serviceGroup, string drill, string drillRun) + { + for (int attempt = 0; attempt < 60; attempt++) + { + var result = await CallToolAsync("resiliency_drill_run_get", new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "drill", drill }, + { "name", drillRun } + }); + var properties = result.AssertProperty("drillRun").AssertProperty("properties"); + AssertNoFailedDrillTasks(properties); + var stages = properties.AssertProperty("supportedVerbsForStage").EnumerateArray().ToArray(); + bool HasVerb(string stageName, string verb) => stages.Any(stage => + stage.AssertProperty("drillRunStage").GetString() == stageName && + stage.AssertProperty("supportedVerbs").EnumerateArray().Any(value => value.GetString() == verb)); + + if (HasVerb("Failover", "Cancel") || HasVerb("Failover", "MarkAsComplete")) + { + return null; + } + if (HasVerb("Failover", "Start")) + { + return "Failover"; + } + if (HasVerb("FaultInjection", "MarkAsComplete")) + { + return "FaultInjection"; + } + await Task.Delay(PollInterval(10000), TestContext.Current.CancellationToken); + } + Assert.Fail("Automatic failover did not reach an actionable or running failover stage."); + return null; + } + + private async Task WaitForStageVerbAsync(string serviceGroup, string drill, string drillRun, string stageName, params string[] expectedVerbs) + { + for (int attempt = 0; attempt < 60; attempt++) + { + var result = await CallToolAsync("resiliency_drill_run_get", new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "drill", drill }, + { "name", drillRun } + }); + var properties = result.AssertProperty("drillRun").AssertProperty("properties"); + AssertNoFailedDrillTasks(properties); + if (properties.AssertProperty("supportedVerbsForStage").EnumerateArray().Any(stage => + stage.AssertProperty("drillRunStage").GetString() == stageName && + stage.AssertProperty("supportedVerbs").EnumerateArray().Any(verb => expectedVerbs.Contains(verb.GetString())))) + { + return; + } + await Task.Delay(PollInterval(10000), TestContext.Current.CancellationToken); + } + Assert.Fail($"Drill run stage '{stageName}' did not offer verb(s) [{string.Join(", ", expectedVerbs)}] within the timeout."); + } + + private static void AssertNoFailedDrillTasks(JsonElement task) + { + if (task.TryGetProperty("status", out var status)) + { + Assert.True(status.GetString() is not ("Failed" or "Cancelled"), $"Drill task failed: {task}"); + } + if (task.TryGetProperty("jobExtendedInfo", out var extended) && + extended.ValueKind == JsonValueKind.Object && + extended.TryGetProperty("tasksList", out var tasks) && + tasks.ValueKind == JsonValueKind.Array) + { + foreach (var child in tasks.EnumerateArray()) + { + AssertNoFailedDrillTasks(child); + } + } + if (task.TryGetProperty("subTasksList", out var subTasks) && subTasks.ValueKind == JsonValueKind.Array) + { + foreach (var child in subTasks.EnumerateArray()) + { + AssertNoFailedDrillTasks(child); } } - - Assert.True(reachedExpectedVerb, $"Drill run stage '{stageName}' did not offer verb(s) [{string.Join(", ", expectedVerbs)}] within the timeout."); } private async Task WaitForDrillRunNoteAsync(string serviceGroup, string drill, string drillRun, string expectedNote) @@ -722,39 +1044,37 @@ public class ResilienceManagementCommandTests( } /// - /// Calls a tool, retrying on the known transient 409 InvalidResourceOperation lock the Drills backend returns - /// immediately after a prior long-running operation on the same drill run completes. CallToolAsync does not - /// throw on non-success responses, so retryable failures are detected by the absence of the expected - /// "result" payload shape rather than by catching an exception. + /// Retries only explicit HTTP 409 conflicts while a prior drill operation releases its lock. /// - private async Task CallToolWithConflictRetryAsync(string toolName, Dictionary parameters) + private async Task CallToolWithConflictRetryAsync(string toolName, Dictionary parameters, int maxAttempts = 6) { - JsonElement? response = null; - for (int attempt = 0; attempt < 6; attempt++) + for (int attempt = 0; attempt < maxAttempts; attempt++) { - response = await CallToolAsync(toolName, parameters); - if (response?.TryGetProperty("result", out _) == true) + var response = await CallToolAsync(toolName, parameters, resultProcessor: element => element); + var status = response.AssertProperty("status").GetInt32(); + if (status == 200) { - return response; + return response.AssertProperty("results"); } - + Assert.True(status == 409, $"Unexpected response from {toolName}: {response}"); if (TestMode != Microsoft.Mcp.Tests.Helpers.TestMode.Playback) { await Task.Delay(TimeSpan.FromSeconds(15), TestContext.Current.CancellationToken); } } - return response; + Assert.Fail($"{toolName} remained in conflict after {maxAttempts} attempts."); + return null; } [Fact] public async Task Should_list_drill_run_resources() { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var drill = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLNAME"); - var drillRun = RegisterOrRetrieveDeploymentOutputVariable("drillRunName", "DRILLRUNNAME"); - var drillRunResource = RegisterOrRetrieveDeploymentOutputVariable("drillRunResourceName", "DRILLRUNRESOURCENAME"); + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSSERVICEGROUPNAME"); + var drill = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLSDRILLNAME"); + var drillRun = RegisterOrRetrieveDeploymentOutputVariable("drillRunName", "DRILLSDRILLRUNNAME"); + var drillRunResource = RegisterOrRetrieveDeploymentOutputVariable("drillRunResourceName", "DRILLSDRILLRUNRESOURCENAME"); var result = await CallToolAsync( "resiliency_drill_run_resource_get", @@ -776,10 +1096,10 @@ public class ResilienceManagementCommandTests( [Fact] public async Task Should_get_drill_run_resource() { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var drill = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLNAME"); - var drillRun = RegisterOrRetrieveDeploymentOutputVariable("drillRunName", "DRILLRUNNAME"); - var drillRunResource = RegisterOrRetrieveDeploymentOutputVariable("drillRunResourceName", "DRILLRUNRESOURCENAME"); + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSSERVICEGROUPNAME"); + var drill = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLSDRILLNAME"); + var drillRun = RegisterOrRetrieveDeploymentOutputVariable("drillRunName", "DRILLSDRILLRUNNAME"); + var drillRunResource = RegisterOrRetrieveDeploymentOutputVariable("drillRunResourceName", "DRILLSDRILLRUNRESOURCENAME"); var result = await CallToolAsync( "resiliency_drill_run_resource_get", @@ -1232,55 +1552,54 @@ public class ResilienceManagementCommandTests( [Fact] public async Task Should_add_or_update_drill_resources() { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLNAME"); - - var listedResources = await CallToolAsync( - "resiliency_drill_resource_get", - new() - { - { "tenant", Settings.TenantId }, - { "service-group", serviceGroup }, - { "drill", drillName } - }); - var firstResource = listedResources.AssertProperty("drillResources").EnumerateArray().First(); - var targetName = firstResource.AssertProperty("id").GetString()?.Split('/').Last(); - Assert.False(string.IsNullOrEmpty(targetName)); - - var resourceResult = await CallToolAsync( - "resiliency_drill_resource_get", - new() - { - { "tenant", Settings.TenantId }, - { "service-group", serviceGroup }, - { "drill", drillName }, - { "name", targetName } - }); - var azureResourceId = resourceResult - .AssertProperty("drillResource") - .AssertProperty("properties") - .AssertProperty("resourceId") - .GetString(); - Assert.False(string.IsNullOrEmpty(azureResourceId)); + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "DRILLSSERVICEGROUPNAME"); + var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLSDRILLNAME"); + var drillResourceId = RegisterOrRetrieveDeploymentOutputVariable("drillResourceId", "DRILLSDRILLRESOURCEID"); var includePayload = new JsonArray { - new JsonObject { ["id"] = azureResourceId } + new JsonObject { ["id"] = drillResourceId } }; - var result = await CallToolAsync( + var result = await CallToolWithConflictRetryAsync( "resiliency_drill_resource_add-or-update", new() { { "tenant", Settings.TenantId }, { "service-group", serviceGroup }, { "drill", drillName }, - { "fault-duration-minutes", "10" }, + { "fault-duration-minutes", "1" }, + { "force-inclusion-and-update", "Disable" }, { "include-resources", includePayload.ToJsonString() } }); var operationId = result.AssertProperty("result").AssertProperty("operationId").GetString(); Assert.False(string.IsNullOrEmpty(operationId)); + // Resource updates do not refresh the drill-level readiness summary. + await ResyncDrillAndWaitAsync(serviceGroup, drillName); + for (int attempt = 0; attempt < 60; attempt++) + { + var current = await CallToolAsync("resiliency_drill_get", new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "name", drillName } + }); + var properties = current.AssertProperty("drill").AssertProperty("properties"); + var chaos = properties.AssertProperty("chaosResourceProperties"); + if (chaos.TryGetProperty("faultDurationInMin", out var duration) && + duration.ValueKind == JsonValueKind.Number && duration.GetInt32() == 1 && + (properties.AssertProperty("executionReadinessState").GetString() == "Ready" || + (properties.TryGetProperty("attentionReason", out var attention) && + attention.ValueKind == JsonValueKind.Object && + attention.TryGetProperty("includedResourceInDrill", out var inclusion) && + inclusion.GetString() == "Exists"))) + { + return; + } + await Task.Delay(PollInterval(10000), TestContext.Current.CancellationToken); + } + Assert.Fail("The drill did not report an included resource after adding the dedicated VM."); } [Fact] @@ -1368,8 +1687,8 @@ public class ResilienceManagementCommandTests( [Fact] public async Task Should_validate_recoveryplan_for_operation() { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var recoveryPlan = RegisterOrRetrieveDeploymentOutputVariable("recoveryPlanName", "RECOVERYPLANNAME"); + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("workflowServiceGroupName", "WORKFLOWSERVICEGROUPNAME"); + var recoveryPlan = RegisterOrRetrieveDeploymentOutputVariable("workflowRecoveryPlanName", "WORKFLOWRECOVERYPLANNAME"); var result = await CallToolAsync( "resiliency_recoveryplan_validateforoperation", @@ -1782,7 +2101,7 @@ public class ResilienceManagementCommandTests( { "subscription", Settings.SubscriptionId }, { "resource-group", resourceGroupName }, { "usage-plan", usagePlanName }, - { "plan-type", "Basic" } + { "plan-type", "Standard" } }); var usagePlan = result.AssertProperty("usagePlan"); @@ -1803,7 +2122,7 @@ public class ResilienceManagementCommandTests( { "subscription", Settings.SubscriptionId }, { "resource-group", resourceGroupName }, { "usage-plan", usagePlanName }, - { "plan-type", "Basic" } + { "plan-type", "Standard" } }); var result = await CallToolAsync( @@ -1860,9 +2179,9 @@ public class ResilienceManagementCommandTests( public async Task Should_delete_usage_plan_enrollment() { var resourceGroupName = RegisterOrRetrieveVariable("deleteEnrollmentResourceGroupName", Settings.ResourceGroupName); - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("deleteEnrollmentServiceGroupName", "SERVICEGROUPNAME"); + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("deleteEnrollmentServiceGroupName", "LIFECYCLESERVICEGROUPNAME"); var usagePlanName = RegisterOrRetrieveDeploymentOutputVariable("deleteEnrollmentUsagePlanName", "USAGEPLANNAME"); - var enrollmentName = RegisterOrRetrieveDeploymentOutputVariable("deleteEnrollmentName", "ENROLLMENTNAME"); + var enrollmentName = RegisterOrRetrieveDeploymentOutputVariable("deleteEnrollmentName", "LIFECYCLEENROLLMENTNAME"); try { diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementRecordedTestsBase.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementRecordedTestsBase.cs new file mode 100644 index 000000000..5b006a18a --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementRecordedTestsBase.cs @@ -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 DisabledDefaultSanitizers => + [ + .. base.DisabledDefaultSanitizers, + "AZSDK2003" + ]; + + public override List 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)=)(?[^&]+)", + GroupForReplace = "value", + Value = "sanitized" + }), + new HeaderRegexSanitizer(new HeaderRegexSanitizerBody("Azure-AsyncOperation") + { + Regex = "([?&](?:t|c|s|h)=)(?[^&]+)", + GroupForReplace = "value", + Value = "sanitized" + }) + ]; + + public override List 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) '(?[a-f0-9-]{36})'", + GroupForReplace = "id", + Value = "00000000-0000-0000-0000-000000000000" + }), + new GeneralRegexSanitizer(new GeneralRegexSanitizerBody + { + Regex = @"(?i)/resourcegroups/(?[^/""\\?]+)", + GroupForReplace = "name", + Value = "Sanitized" + }), + new GeneralRegexSanitizer(new GeneralRegexSanitizerBody + { + Regex = @"(?i)resource group '(?[^']+)'", + 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 UriRegexSanitizers => + [ + .. base.UriRegexSanitizers, + new UriRegexSanitizer(new UriRegexSanitizerBody + { + Regex = "([?&](?:t|c|s|h)=)(?[^&]+)", + 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 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() + }) + ]; +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs index 29097f8f0..188383723 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs @@ -1,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(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(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(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(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(BinaryData.FromObjectAsJson(new + ResilienceManagementJobErrorInfo error = ModelReaderWriter.Read(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(BinaryData.FromObjectAsJson(new + ResilienceManagementJobErrorInfo providerError = ModelReaderWriter.Read(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()); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/UsagePlans/UsagePlanCreateCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/UsagePlans/UsagePlanCreateCommandTests.cs index 88e3915a6..131a6765c 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/UsagePlans/UsagePlanCreateCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/UsagePlans/UsagePlanCreateCommandTests.cs @@ -15,7 +15,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Tests.UsagePlans; public sealed class UsagePlanCreateCommandTests : SubscriptionCommandUnitTestsBase { - 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(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + [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); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json index e102616f6..01f93007f 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.ResilienceManagement.Tests", - "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_d1a71fffa6" + "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_f9a321cb91" } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 index dde9c06e4..f6d136c1c 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 @@ -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)) { diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-drills-post.ps1 b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-drills-post.ps1 new file mode 100644 index 000000000..42883e23b --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-drills-post.ps1 @@ -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))." diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-drills.bicep b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-drills.bicep new file mode 100644 index 000000000..ca0cb17e1 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-drills.bicep @@ -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 diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-post.ps1 b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-post.ps1 index 3e4042104..bdfb8f567 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-post.ps1 +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-post.ps1 @@ -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']))." diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-pre.ps1 b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-pre.ps1 new file mode 100644 index 000000000..4b1952210 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-pre.ps1 @@ -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 +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources.bicep b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources.bicep index 74be3b4d3..cadf21d3a 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources.bicep +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources.bicep @@ -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