From b10fb6bfc8f857a7bc2e4d53912ef4f3c2177dbd Mon Sep 17 00:00:00 2001 From: vineetkalki <129124229+vineetkalki@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:00:48 -0700 Subject: [PATCH] Add Azure IoT Hub routing endpoint health and diagnostics tools (#3894) * Add Azure IoT Hub routing endpoint health and diagnostics tools Provide a current endpoint-health snapshot and historical routing evidence, including downstream resource existence and per-metric availability without inferring overall hub health. Use a dedicated routing service with cloud-aware ARM validation, SDK-default retries, bounded operations, and cancellation handling. Include command documentation, registration, unit tests, recorded tests, and routing test infrastructure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Tighten IoT Hub routing metric fallback and ARM link validation Only retry batched target metrics individually when Azure Monitor rejects the batch with 400, the incompatible-metric case. Throttling and server errors now fail the batch after SDK retries instead of fanning out into per-metric requests that consume the operation budget. Validate each ARM request URI, including nextLink continuations, before resolving the tenant or credential so untrusted destinations fail before any authentication work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use IAzureService endpoint validation for IoT Hub routing ARM requests #3884 removed the static EndpointValidator overload that took an executing tool namespace, so the routing service no longer compiles against main (CS1739). Validate ARM request and nextLink URIs through IAzureService.ValidateAzureServiceEndpoint, as IoTHubDeviceService now does. Tests use AzureServiceTestHelpers so endpoint validation stays real and shared ARM clients route through the test handler. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Resolve IoT Hub routing targets only from the endpoint URI Key-based endpoints have no endpointUri, and IoT Hub endpoint ids are opaque GUIDs rather than ARM resource IDs. The mapper fell back to the entity path or container name as the namespace or account name, so diagnostics checked a resource that does not exist and reported an existing target as notFound. Derive the target name only from the endpoint URI host so these endpoints report an unresolved target, and use opaque ids in the mapper fixture to match service responses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Document unresolved IoT Hub routing targets for key-based endpoints Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Configure IoT Hub live test routing and devices in the post script The live pipeline failed in test-resources-post.ps1 because the azure-iot CLI extension it used cannot be installed on network-isolated agents. Seed the test devices through the IoT Hub registry REST API with Microsoft Entra ID instead, and grant the test principal IoT Hub Data Contributor. Also add the identity-based Service Bus routing endpoints in the post script rather than the template. IoT Hub validates those endpoints when they are created and rejects them (IH400117) until the routing identity's Service Bus Data Sender assignment takes effect. ARM cannot wait for that, so the post script retries until it does. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Validate IoT Hub resource names before building ARM resource IDs Hub and resource group names were interpolated into ARM resource IDs. ResourceIdentifier accepts traversal segments, and URI normalization then sent the request elsewhere: rg1/../rg2 and rg1\..\rg2 read the hub from resource group rg2, and .. escaped to the subscription scope. Reject names outside the Azure naming rules before any credential or network work. The check runs in the shared routing service path used by both routing commands, and in IoTHubService.GetIoTHub, which serves hub get and, through the hostname resolver, the device and query tools. Resource group names follow the documented rule: 1-90 letters or digits (Char.IsLetterOrDigit), underscores, hyphens, periods, or parentheses, not ending with a period. Routing targets built from the hub's endpoint configuration are reported as unresolved unless every name is a single path segment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- servers/Azure.Mcp.Server/README.md | 4 +- .../vineetkalki-iothub-routing-tools.yaml | 10 + .../Azure.Mcp.Server/docs/azmcp-commands.md | 31 + .../Azure.Mcp.Server/docs/e2eTestPrompts.md | 6 + .../src/Resources/consolidated-tools.json | 66 + tools/Azure.Mcp.Tools.IoTHub/cspell.yaml | 6 + .../src/Azure.Mcp.Tools.IoTHub.csproj | 1 + .../src/Commands/IoTHub/IoTHubGetCommand.cs | 30 +- .../src/Commands/IoTHubJsonContext.cs | 16 + .../src/Commands/IoTHubValidation.cs | 84 ++ ...IoTHubRoutingEndpointDiagnosticsCommand.cs | 108 ++ .../IoTHubRoutingEndpointHealthCommand.cs | 93 ++ .../Azure.Mcp.Tools.IoTHub/src/IoTHubSetup.cs | 9 + .../src/Models/EndpointHealthData.cs | 15 + .../Models/EndpointHealthDataListResult.cs | 11 + .../src/Models/IoTHubDescription.cs | 3 +- .../src/Models/IoTHubProperties.cs | 2 + .../src/Models/RoutingDiagnosticError.cs | 13 + .../src/Models/RoutingEndpointDetails.cs | 19 + .../src/Models/RoutingEndpointDiagnostic.cs | 21 + .../src/Models/RoutingEndpointDiagnostics.cs | 8 + .../src/Models/RoutingEndpointHealthResult.cs | 7 + .../Models/RoutingEndpointHealthSnapshot.cs | 13 + .../src/Models/RoutingEndpointProperties.cs | 29 + .../src/Models/RoutingEndpoints.cs | 17 + .../src/Models/RoutingHubEmitted.cs | 8 + .../src/Models/RoutingMetricEvidence.cs | 14 + .../src/Models/RoutingObservationWindow.cs | 9 + .../src/Models/RoutingProperties.cs | 9 + .../src/Models/RoutingTargetEmitted.cs | 17 + .../src/Models/RoutingTargetInfo.cs | 13 + ...IoTHubRoutingEndpointDiagnosticsOptions.cs | 34 + .../IoTHubRoutingEndpointHealthOptions.cs | 25 + .../src/Routing/MetricFieldNameBuilder.cs | 108 ++ .../src/Routing/RoutingDiagnosticsWindow.cs | 118 ++ .../src/Routing/RoutingEndpointMapper.cs | 63 + .../Routing/RoutingMetricEvidenceBuilder.cs | 267 ++++ .../src/Services/ArmGetResult.cs | 11 + .../src/Services/ExistenceResult.cs | 11 + .../src/Services/IIoTHubRoutingService.cs | 28 + .../src/Services/IoTHubRoutingService.cs | 1218 +++++++++++++++++ .../src/Services/IoTHubService.cs | 5 +- .../src/Services/ObservationWindow.cs | 18 + .../src/Services/ResolvedTarget.cs | 11 + .../src/Services/RoutingTargetDescriptor.cs | 9 + .../IoTHubCommandTests.cs | 106 ++ .../IoTHubValidationTests.cs | 48 + ...bRoutingEndpointDiagnosticsCommandTests.cs | 271 ++++ ...IoTHubRoutingEndpointHealthCommandTests.cs | 171 +++ .../Routing/RoutingDiagnosticsWindowTests.cs | 72 + .../Routing/RoutingEndpointMapperTests.cs | 107 ++ .../RoutingMetricEvidenceBuilderTests.cs | 145 ++ .../IoTHubRoutingServiceDiagnosticsTests.cs | 739 ++++++++++ .../Services/IoTHubRoutingServiceTests.cs | 315 +++++ .../Services/IoTHubServiceTests.cs | 85 ++ .../RoutingDiagnosticsHttpMessageHandler.cs | 21 + .../Services/SequenceHttpMessageHandler.cs | 19 + .../Azure.Mcp.Tools.IoTHub.Tests/assets.json | 2 +- .../tests/test-resources-post.ps1 | 177 ++- .../tests/test-resources.bicep | 70 +- 60 files changed, 4883 insertions(+), 83 deletions(-) create mode 100644 servers/Azure.Mcp.Server/changelog-entries/vineetkalki-iothub-routing-tools.yaml create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHubValidation.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/Routing/IoTHubRoutingEndpointDiagnosticsCommand.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/Routing/IoTHubRoutingEndpointHealthCommand.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/EndpointHealthData.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/EndpointHealthDataListResult.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingDiagnosticError.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointDetails.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointDiagnostic.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointDiagnostics.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointHealthResult.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointHealthSnapshot.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointProperties.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpoints.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingHubEmitted.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingMetricEvidence.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingObservationWindow.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingProperties.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingTargetEmitted.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingTargetInfo.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/Routing/IoTHubRoutingEndpointDiagnosticsOptions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/Routing/IoTHubRoutingEndpointHealthOptions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Routing/MetricFieldNameBuilder.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Routing/RoutingDiagnosticsWindow.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Routing/RoutingEndpointMapper.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Routing/RoutingMetricEvidenceBuilder.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Services/ArmGetResult.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Services/ExistenceResult.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Services/IIoTHubRoutingService.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Services/IoTHubRoutingService.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Services/ObservationWindow.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Services/ResolvedTarget.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Services/RoutingTargetDescriptor.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/IoTHubValidationTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/IoTHubRoutingEndpointDiagnosticsCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/IoTHubRoutingEndpointHealthCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/RoutingDiagnosticsWindowTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/RoutingEndpointMapperTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/RoutingMetricEvidenceBuilderTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/IoTHubRoutingServiceDiagnosticsTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/IoTHubRoutingServiceTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/IoTHubServiceTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/RoutingDiagnosticsHttpMessageHandler.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/SequenceHttpMessageHandler.cs diff --git a/servers/Azure.Mcp.Server/README.md b/servers/Azure.Mcp.Server/README.md index 6151fa756..b7da3c4bb 100644 --- a/servers/Azure.Mcp.Server/README.md +++ b/servers/Azure.Mcp.Server/README.md @@ -1225,6 +1225,8 @@ For customer-hosted ADME instances, pass the resource application ID or App ID U * "Query all devices in IoT Hub 'my-iot-hub' in resource group 'my-resource-group'" * "Compile an IoT Hub query for devices where reported batteryLevel is less than 20" * "Discover the queryable device twin fields in IoT Hub 'my-iot-hub'" +* "Show routing endpoint health for IoT Hub 'my-iot-hub' in resource group 'my-resource-group'" +* "Show routing endpoint diagnostics for IoT Hub 'my-iot-hub' in resource group 'my-resource-group'" ### 🏭 Azure IoT Operations @@ -1501,7 +1503,7 @@ The Azure MCP Server provides tools for interacting with **45+ Azure service are - πŸ“ **Azure File Shares** - Azure managed file share operations - ⚑ **Azure Functions** - Function App management and functions project files, language support, and templates source code - πŸ’‘ **Azure Insights** - Derive infrastructure insights from Azure Resource Graph patterns -- 🌐 **Azure IoT Hub** - IoT Hub resource discovery and details +- 🌐 **Azure IoT Hub** - IoT Hub resource discovery, details, routing endpoint health, and routing diagnostics - 🏭 **Azure IoT Operations** - IoT Operations instance discovery and details - πŸ”‘ **Azure Key Vault** - Secrets, keys, and certificates - ☸️ **Azure Kubernetes Service (AKS)** - Container orchestration diff --git a/servers/Azure.Mcp.Server/changelog-entries/vineetkalki-iothub-routing-tools.yaml b/servers/Azure.Mcp.Server/changelog-entries/vineetkalki-iothub-routing-tools.yaml new file mode 100644 index 000000000..512aba743 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/vineetkalki-iothub-routing-tools.yaml @@ -0,0 +1,10 @@ +changes: + - section: "Features Added" + description: | + Added Azure IoT Hub message-routing troubleshooting tools: + - `iothub_routing_endpoint-health`: Get the current IoT Hub-reported health snapshot for one or all routing custom endpoints + - `iothub_routing_endpoint-diagnostics`: Get time-windowed routing evidence for one or all custom endpoints, including IoT Hub routing delivery metrics, current target existence, and target metrics for Event Hubs, Service Bus, Blob Storage, and Cosmos DB + - section: "Features Added" + description: "`iothub_hub_get` now returns the hub's configured message-routing custom endpoints in `routingEndpoints`." + - section: "Bugs Fixed" + description: "Azure IoT Hub tools now reject hub and resource group names that don't follow Azure naming rules before calling Azure. Previously, values such as `rg1/../rg2` could send the request to a different resource group or IoT Hub." diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index c37786a25..28afe96b6 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3393,8 +3393,39 @@ azmcp iothub query run --subscription \ [--from ] \ [--logical-operator ] \ [--max-count ] + +# Get the current IoT Hub-reported health snapshot for message-routing endpoints +# ❌ Destructive | βœ… Idempotent | ❌ OpenWorld | βœ… ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp iothub routing endpoint-health --subscription \ + --resource-group \ + --hub-name \ + [--endpoint-name ] + +# Get factual hub and target metric evidence for IoT Hub message-routing endpoints +# ❌ Destructive | βœ… Idempotent | ❌ OpenWorld | βœ… ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp iothub routing endpoint-diagnostics --subscription \ + --resource-group \ + --hub-name \ + [--endpoint-name ] \ + [--start-time --end-time ] \ + [--interval ] ``` +Diagnostics defaults to the previous 24 hours with `PT1H` buckets. `--start-time` and `--end-time` must +be supplied together. The maximum observation window is 30 days, and the selected window and interval +cannot exceed 720 buckets. + +Diagnostics distinguishes values returned, no values returned, failed queries, and partial evidence +through per-metric availability metadata. Missing metric values do not mean zero traffic. Target +metrics describe the parent resource and can include other clients; they do not prove endpoint +delivery. Current target existence is separate from historical metrics, with any existence-check +error under `target.error`. Targets are resolved from each endpoint's `endpointUri`; key-based Event +Hubs, Service Bus, and Storage endpoints have no endpoint URI, so they report `target.resolutionStatus` +as `unresolved` with no target evidence. These routing tools do not determine overall hub health or device ingress +health. Each routing operation has a +100-second timeout covering all requests and retries. Expiration returns HTTP 408 rather than +presenting incomplete work as a successful result; caller cancellation is preserved. + ### Azure IoT Operations ```bash diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index c89b07150..224194a6d 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -878,6 +878,12 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat | iothub_query_run | Run an IoT Hub device query equivalent to "SELECT * FROM devices" against IoT Hub in resource group ; do not use the device-list operation | none | | iothub_query_run | Find devices in IoT Hub where reported batteryLevel is less than 20 | none | | iothub_query_run | Find devices in IoT Hub where tag environment equals 'production' | none | +| iothub_routing_endpoint-diagnostics | Show routing endpoint diagnostics evidence for IoT Hub in resource group | none | +| iothub_routing_endpoint-diagnostics | Get routing endpoint diagnostics metrics for IoT Hub from through | none | +| iothub_routing_endpoint-diagnostics | Check whether routing endpoint on IoT Hub has a missing target, unavailable metrics, or observed delivery failures in the past six hours | none | +| iothub_routing_endpoint-diagnostics | Show which routing metrics returned no data for IoT Hub without assuming the hub is idle or healthy | none | +| iothub_routing_endpoint-health | Show routing endpoint health for IoT Hub in resource group | none | +| iothub_routing_endpoint-health | Get the current routing endpoint health for endpoint on IoT Hub | none | ## Azure IoT Operations diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index cc2badaa5..137ad5846 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -3252,6 +3252,72 @@ "iothub_hub_get" ] }, + { + "name": "get_azure_iot_hub_routing_endpoint_health", + "description": "Get the current health status that an Azure IoT Hub reports for its message-routing custom endpoints, including the last known error and the last send attempt times.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool only retrieves information about Azure IoT Hub routing endpoints without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "iothub_routing_endpoint-health" + ] + }, + { + "name": "get_azure_iot_hub_routing_endpoint_diagnostics", + "description": "Get time-windowed diagnostic evidence for Azure IoT Hub message-routing custom endpoints: routing delivery and latency metrics, current target resource existence, and Azure Monitor metrics from Event Hubs, Service Bus, Storage, and Cosmos DB targets.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool only retrieves information about Azure IoT Hub routing endpoints and their targets without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "iothub_routing_endpoint-diagnostics" + ] + }, { "name": "get_azure_iot_operations", "description": "List and get Azure IoT Operations instances (Microsoft.IoTOperations/instances) in a subscription or resource group.", diff --git a/tools/Azure.Mcp.Tools.IoTHub/cspell.yaml b/tools/Azure.Mcp.Tools.IoTHub/cspell.yaml index 326c74839..2290ed0f1 100644 --- a/tools/Azure.Mcp.Tools.IoTHub/cspell.yaml +++ b/tools/Azure.Mcp.Tools.IoTHub/cspell.yaml @@ -10,4 +10,10 @@ words: - brien - detag - iothub + - lookback + - metadatavalues + - metricnames + - metricnamespace - notjson + - sbqueue + - sbtopic diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Azure.Mcp.Tools.IoTHub.csproj b/tools/Azure.Mcp.Tools.IoTHub/src/Azure.Mcp.Tools.IoTHub.csproj index 2ce76b298..606a1352f 100644 --- a/tools/Azure.Mcp.Tools.IoTHub/src/Azure.Mcp.Tools.IoTHub.csproj +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Azure.Mcp.Tools.IoTHub.csproj @@ -14,6 +14,7 @@ + diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubGetCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubGetCommand.cs index 68d70d80c..96d58dd61 100644 --- a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubGetCommand.cs +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubGetCommand.cs @@ -18,7 +18,8 @@ namespace Azure.Mcp.Tools.IoTHub.Commands.IoTHub; Title = "Get IoT Hub", Description = """ Get IoT Hub details by name in a resource group of a subscription. - Returns the IoT Hub with id, name, location, resourceGroup, subscriptionId, sku, capacity, state, and hostName. + Returns the IoT Hub with id, name, location, resourceGroup, subscriptionId, sku, capacity, state, hostName, + and the configured message-routing custom endpoints (routingEndpoints) with their target resource details. """, OperationPlane = ToolOperationPlane.Control, Destructive = false, @@ -39,11 +40,7 @@ public sealed class IoTHubGetCommand( public override void ValidateOptions(IoTHubGetOptions options, ValidationResult validationResult) { base.ValidateOptions(options, validationResult); - - if (!IsValidIoTHubName(options.HubName)) - { - validationResult.Errors.Add("--hub-name must be 3-50 characters long and contain only letters, numbers, or hyphens, and it cannot end with a hyphen."); - } + IoTHubValidation.ValidateHubName(options.HubName, validationResult); } public override async Task ExecuteAsync( @@ -78,25 +75,4 @@ public sealed class IoTHubGetCommand( } public record IoTHubGetCommandResult(IoTHubDescription IoTHub, bool AreResultsTruncated); - - private static bool IsValidIoTHubName(string value) - { - if (value.Length is < 3 or > 50 || value[^1] == '-') - { - return false; - } - - foreach (var ch in value) - { - var isAlphaNumeric = (ch >= 'a' && ch <= 'z') || - (ch >= 'A' && ch <= 'Z') || - (ch >= '0' && ch <= '9'); - if (!isAlphaNumeric && ch != '-') - { - return false; - } - } - - return true; - } } diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHubJsonContext.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHubJsonContext.cs index ed1f6ec8d..8eea56302 100644 --- a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHubJsonContext.cs +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHubJsonContext.cs @@ -29,6 +29,22 @@ namespace Azure.Mcp.Tools.IoTHub.Commands; [JsonSerializable(typeof(JsonElement))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(object))] +[JsonSerializable(typeof(RoutingEndpointHealthResult))] +[JsonSerializable(typeof(RoutingEndpointHealthSnapshot))] +[JsonSerializable(typeof(RoutingEndpointDiagnostics))] +[JsonSerializable(typeof(RoutingEndpointDiagnostic))] +[JsonSerializable(typeof(RoutingEndpointDetails))] +[JsonSerializable(typeof(RoutingObservationWindow))] +[JsonSerializable(typeof(RoutingTargetInfo))] +[JsonSerializable(typeof(RoutingHubEmitted))] +[JsonSerializable(typeof(RoutingTargetEmitted))] +[JsonSerializable(typeof(RoutingMetricEvidence))] +[JsonSerializable(typeof(RoutingDiagnosticError))] +[JsonSerializable(typeof(EndpointHealthDataListResult))] +[JsonSerializable(typeof(EndpointHealthData))] +[JsonSerializable(typeof(double))] +[JsonSerializable(typeof(long))] +[JsonSerializable(typeof(string))] [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHubValidation.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHubValidation.cs new file mode 100644 index 000000000..edf898b16 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHubValidation.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text; +using Microsoft.Mcp.Core.Commands; + +namespace Azure.Mcp.Tools.IoTHub.Commands; + +// Shared option validation for IoT Hub commands. +internal static class IoTHubValidation +{ + public const string InvalidHubNameError = + "--hub-name must be 3-50 characters long and contain only letters, numbers, or hyphens, and it cannot end with a hyphen."; + + public const string InvalidResourceGroupError = + "--resource-group must be 1-90 characters long and contain only letters, digits, underscores, hyphens, periods, or parentheses, and it cannot end with a period."; + + public static void ValidateHubName(string hubName, ValidationResult validationResult) + { + if (!IsValidIoTHubName(hubName)) + { + validationResult.Errors.Add(InvalidHubNameError); + } + } + + // Hub and resource group names are interpolated into ARM resource IDs. A value outside the Azure naming rules can + // add path segments, such as "rg1/../rg2", that URI normalization resolves to a different resource group. + public static void ThrowIfInvalidResourceNames(string hubName, string resourceGroup) + { + if (!IsValidResourceGroupName(resourceGroup)) + { + throw new ArgumentException(InvalidResourceGroupError, nameof(resourceGroup)); + } + + if (!IsValidIoTHubName(hubName)) + { + throw new ArgumentException(InvalidHubNameError, nameof(hubName)); + } + } + + // Azure resource group names are 1-90 underscores, hyphens, periods, parentheses, and letters or digits as defined by + // Char.IsLetterOrDigit, and can't end with a period. Runes keep letters outside the Basic Multilingual Plane valid. + public static bool IsValidResourceGroupName([NotNullWhen(true)] string? value) + { + if (string.IsNullOrEmpty(value) || value[^1] == '.') + { + return false; + } + + var length = 0; + foreach (var rune in value.EnumerateRunes()) + { + if (++length > 90 || + !(Rune.IsLetterOrDigit(rune) || rune.Value is '_' or '-' or '.' or '(' or ')')) + { + return false; + } + } + + return true; + } + + public static bool IsValidIoTHubName(string value) + { + if (value.Length is < 3 or > 50 || value[^1] == '-') + { + return false; + } + + foreach (var ch in value) + { + var isAlphaNumeric = (ch >= 'a' && ch <= 'z') || + (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9'); + if (!isAlphaNumeric && ch != '-') + { + return false; + } + } + + return true; + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Routing/IoTHubRoutingEndpointDiagnosticsCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Routing/IoTHubRoutingEndpointDiagnosticsCommand.cs new file mode 100644 index 000000000..6f6cb790d --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Routing/IoTHubRoutingEndpointDiagnosticsCommand.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Options.Routing; +using Azure.Mcp.Tools.IoTHub.Routing; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; + +namespace Azure.Mcp.Tools.IoTHub.Commands.Routing; + +[CommandMetadata( + Id = "c2a9f4e1-7b6d-4f80-9a35-1e8c6d2b4f79", + Name = "endpoint-diagnostics", + Title = "Get IoT Hub Routing Endpoint Diagnostics", + Description = """ + Get factual time-windowed diagnostic evidence for one or all IoT Hub message-routing custom + endpoints. Returns per-endpoint IoT Hub RoutingDeliveries and RoutingDeliveryLatency buckets, + current target existence, and native target-resource Azure Monitor metrics for Event Hubs, + Service Bus, Blob Storage, and Cosmos DB. The tool does not infer health, confidence, or likely + cause or assess whole-hub health. No returned values do not mean zero activity. Target metrics + describe the shared parent resource and cannot be attributed to this endpoint. + Use paired --start-time and --end-time for an absolute UTC range; if omitted, the previous + 24 hours are used. --interval defaults to PT1H. At most 720 buckets are allowed. + Requires hub-name and resource-group. + """, + OperationPlane = ToolOperationPlane.Both, + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + Secret = false, + LocalRequired = false)] +public sealed class IoTHubRoutingEndpointDiagnosticsCommand( + ILogger logger, + IIoTHubRoutingService service, + ISubscriptionResolver subscriptionResolver) + : BaseIoTHubCommand(subscriptionResolver) +{ + private readonly ILogger _logger = logger; + private readonly IIoTHubRoutingService _service = service; + + public override void ValidateOptions( + IoTHubRoutingEndpointDiagnosticsOptions options, + ValidationResult validationResult) + { + base.ValidateOptions(options, validationResult); + IoTHubValidation.ValidateHubName(options.HubName, validationResult); + RoutingDiagnosticsWindow.Validate( + options.StartTime, + options.EndTime, + options.Interval, + validationResult); + } + + public override async Task ExecuteAsync( + CommandContext context, + IoTHubRoutingEndpointDiagnosticsOptions options, + CancellationToken cancellationToken) + { + try + { + var result = await _service.GetRoutingEndpointDiagnostics( + options.HubName, + options.ResourceGroup, + options.Subscription!, + options.EndpointName, + options.StartTime, + options.EndTime, + options.Interval, + options.Tenant, + cancellationToken); + + context.Response.Results = ResponseResult.Create( + result, + IoTHubJsonContext.Default.RoutingEndpointDiagnostics); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error getting routing endpoint diagnostics for IoT Hub '{HubName}' in resource group '{ResourceGroup}' and subscription '{Subscription}'.", + options.HubName, + options.ResourceGroup, + options.Subscription); + HandleException(context, ex); + } + + return context.Response; + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + KeyNotFoundException => ex.Message, + RequestFailedException requestFailed when requestFailed.Status == (int)HttpStatusCode.NotFound => + "The IoT Hub was not found. Verify the hub name, resource group, and subscription.", + RequestFailedException requestFailed when requestFailed.Status == (int)HttpStatusCode.Forbidden => + "Authorization failed reading IoT Hub routing diagnostics. Assign Reader on the IoT Hub or a containing scope.", + RequestFailedException => + "IoT Hub routing diagnostics could not be read.", + _ => base.GetErrorMessage(ex) + }; +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Routing/IoTHubRoutingEndpointHealthCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Routing/IoTHubRoutingEndpointHealthCommand.cs new file mode 100644 index 000000000..c5c30a17c --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Routing/IoTHubRoutingEndpointHealthCommand.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Options.Routing; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; + +namespace Azure.Mcp.Tools.IoTHub.Commands.Routing; + +[CommandMetadata( + Id = "3f2b9a4e-7c1d-4e8a-9b6f-2a4d8c1e5f73", + Name = "endpoint-health", + Title = "Get IoT Hub Routing Endpoint Health", + Description = """ + Get the current IoT Hub-reported health snapshot for one or all message-routing custom endpoints. + Returns the routingEndpointsHealth fields endpointId, endpointName, healthStatus, lastKnownError, + lastKnownErrorTime, lastSuccessfulSendAttemptTime, and lastSendAttemptTime without applying a time + window, querying metrics, or inferring health. This is not a whole-hub health assessment. + Requires hub-name and resource-group. + """, + OperationPlane = ToolOperationPlane.Control, + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + Secret = false, + LocalRequired = false)] +public sealed class IoTHubRoutingEndpointHealthCommand( + ILogger logger, + IIoTHubRoutingService service, + ISubscriptionResolver subscriptionResolver) + : BaseIoTHubCommand(subscriptionResolver) +{ + private readonly ILogger _logger = logger; + private readonly IIoTHubRoutingService _service = service; + + public override void ValidateOptions(IoTHubRoutingEndpointHealthOptions options, ValidationResult validationResult) + { + base.ValidateOptions(options, validationResult); + IoTHubValidation.ValidateHubName(options.HubName, validationResult); + } + + public override async Task ExecuteAsync( + CommandContext context, + IoTHubRoutingEndpointHealthOptions options, + CancellationToken cancellationToken) + { + try + { + var endpoints = await _service.GetRoutingEndpointHealth( + options.HubName, + options.ResourceGroup, + options.Subscription!, + options.EndpointName, + options.Tenant, + cancellationToken) + ?? []; + + context.Response.Results = ResponseResult.Create( + new RoutingEndpointHealthResult(endpoints), + IoTHubJsonContext.Default.RoutingEndpointHealthResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error getting routing endpoint health for IoT Hub '{HubName}' in resource group '{ResourceGroup}' and subscription '{Subscription}'.", + options.HubName, + options.ResourceGroup, + options.Subscription); + HandleException(context, ex); + } + + return context.Response; + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + KeyNotFoundException => ex.Message, + RequestFailedException requestFailed when requestFailed.Status == (int)HttpStatusCode.NotFound => + "The IoT Hub was not found. Verify the hub name, resource group, and subscription.", + RequestFailedException requestFailed when requestFailed.Status == (int)HttpStatusCode.Forbidden => + "Authorization failed reading IoT Hub routing endpoint health. Assign Reader on the IoT Hub or a containing scope.", + RequestFailedException => + "IoT Hub routing endpoint health could not be read.", + _ => base.GetErrorMessage(ex) + }; +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/IoTHubSetup.cs b/tools/Azure.Mcp.Tools.IoTHub/src/IoTHubSetup.cs index 98201a7d0..b9f4a426d 100644 --- a/tools/Azure.Mcp.Tools.IoTHub/src/IoTHubSetup.cs +++ b/tools/Azure.Mcp.Tools.IoTHub/src/IoTHubSetup.cs @@ -4,6 +4,7 @@ using Azure.Mcp.Tools.IoTHub.Commands.Device; using Azure.Mcp.Tools.IoTHub.Commands.IoTHub; using Azure.Mcp.Tools.IoTHub.Commands.Query; +using Azure.Mcp.Tools.IoTHub.Commands.Routing; using Azure.Mcp.Tools.IoTHub.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Mcp.Core.Areas; @@ -22,6 +23,7 @@ public class IoTHubSetup : IAreaSetup services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -29,6 +31,8 @@ public class IoTHubSetup : IAreaSetup services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } public CommandGroup RegisterCommands(IServiceProvider serviceProvider) @@ -55,6 +59,11 @@ public class IoTHubSetup : IAreaSetup iothub.AddSubGroup(query); query.AddCommand(serviceProvider); + var routing = new CommandGroup("routing", "IoT Hub message-routing operations."); + iothub.AddSubGroup(routing); + routing.AddCommand(serviceProvider); + routing.AddCommand(serviceProvider); + return iothub; } } diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/EndpointHealthData.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/EndpointHealthData.cs new file mode 100644 index 000000000..6d34c5800 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/EndpointHealthData.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +// Health data for a single routing endpoint from the routingEndpointsHealth REST API. +public class EndpointHealthData +{ + public string? EndpointId { get; set; } + public string? HealthStatus { get; set; } + public string? LastKnownError { get; set; } + public string? LastKnownErrorTime { get; set; } + public string? LastSuccessfulSendAttemptTime { get; set; } + public string? LastSendAttemptTime { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/EndpointHealthDataListResult.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/EndpointHealthDataListResult.cs new file mode 100644 index 000000000..b268389a7 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/EndpointHealthDataListResult.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +// Paged result of EndpointHealthData from the routingEndpointsHealth REST API. +public class EndpointHealthDataListResult +{ + public List? Value { get; set; } + public string? NextLink { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubDescription.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubDescription.cs index 99b834166..019e230ef 100644 --- a/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubDescription.cs +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubDescription.cs @@ -12,4 +12,5 @@ public record IoTHubDescription( string Sku, long Capacity, string State, - string HostName); + string HostName, + IReadOnlyList? RoutingEndpoints = null); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubProperties.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubProperties.cs index 8cf45f83b..af8aab434 100644 --- a/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubProperties.cs +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubProperties.cs @@ -8,4 +8,6 @@ public class IoTHubProperties public string? State { get; set; } public string? HostName { get; set; } + + public RoutingProperties? Routing { get; set; } } diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingDiagnosticError.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingDiagnosticError.cs new file mode 100644 index 000000000..9a8ab89bf --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingDiagnosticError.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record RoutingDiagnosticError( + string Source, + string Operation, + string? ResourceId, + int? StatusCode, + string? Code, + string? RequiredRole, + string Message); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointDetails.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointDetails.cs new file mode 100644 index 000000000..67dd9ab26 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointDetails.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +// One routing endpoint's non-secret configuration. +public record RoutingEndpointDetails( + string Name, + string EndpointType, + string? EndpointResourceName, + string? SubscriptionId, + string? ResourceGroup, + string? EndpointUri, + string? EntityPath, + string? ContainerName, + string? DatabaseName, + string? AuthenticationType, + int? BatchFrequencyInSeconds = null, + string? EndpointId = null); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointDiagnostic.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointDiagnostic.cs new file mode 100644 index 000000000..9a5d5f7ca --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointDiagnostic.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record RoutingEndpointDiagnostic( + string EndpointId, + string Name, + string EndpointType, + string? AuthenticationType, + string? EndpointUri, + string? SubscriptionId, + string? ResourceGroup, + string? EndpointResourceName, + string? EntityPath, + string? ContainerName, + string? DatabaseName, + int? BatchFrequencyInSeconds, + RoutingTargetInfo Target, + RoutingHubEmitted HubEmitted, + RoutingTargetEmitted TargetEmitted); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointDiagnostics.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointDiagnostics.cs new file mode 100644 index 000000000..4af06c851 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointDiagnostics.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record RoutingEndpointDiagnostics( + RoutingObservationWindow ObservationWindow, + List Endpoints); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointHealthResult.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointHealthResult.cs new file mode 100644 index 000000000..c43fffae4 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointHealthResult.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record RoutingEndpointHealthResult( + List Endpoints); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointHealthSnapshot.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointHealthSnapshot.cs new file mode 100644 index 000000000..274485824 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointHealthSnapshot.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record RoutingEndpointHealthSnapshot( + string EndpointId, + string EndpointName, + string? HealthStatus, + string? LastKnownError = null, + string? LastKnownErrorTime = null, + string? LastSuccessfulSendAttemptTime = null, + string? LastSendAttemptTime = null); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointProperties.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointProperties.cs new file mode 100644 index 000000000..47b5f6f20 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpointProperties.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +// Shared deserialization shape for every routing custom-endpoint kind. Kind-specific fields stay null +// when absent. Intentionally omits connection strings / keys / identity so secrets are never deserialized. +public class RoutingEndpointProperties +{ + public string? Id { get; set; } + + public string? Name { get; set; } + + public string? SubscriptionId { get; set; } + + public string? ResourceGroup { get; set; } + + public string? EndpointUri { get; set; } + + public string? EntityPath { get; set; } + + public string? ContainerName { get; set; } + + public string? DatabaseName { get; set; } + + public string? AuthenticationType { get; set; } + + public int? BatchFrequencyInSeconds { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpoints.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpoints.cs new file mode 100644 index 000000000..797457c32 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingEndpoints.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public class RoutingEndpoints +{ + public List? EventHubs { get; set; } + + public List? ServiceBusQueues { get; set; } + + public List? ServiceBusTopics { get; set; } + + public List? StorageContainers { get; set; } + + public List? CosmosDBSqlContainers { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingHubEmitted.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingHubEmitted.cs new file mode 100644 index 000000000..bb820f4ef --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingHubEmitted.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record RoutingHubEmitted( + RoutingMetricEvidence RoutingMetrics, + List Errors); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingMetricEvidence.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingMetricEvidence.cs new file mode 100644 index 000000000..72dc2286a --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingMetricEvidence.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record RoutingMetricEvidence( + string MetricNamespace, + Dictionary WindowAggregates, + List> Buckets) +{ + public Dictionary MetricAvailability { get; init; } = []; +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingObservationWindow.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingObservationWindow.cs new file mode 100644 index 000000000..19fb76f6e --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingObservationWindow.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record RoutingObservationWindow( + string StartTime, + string EndTime, + string Interval); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingProperties.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingProperties.cs new file mode 100644 index 000000000..ede19797d --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingProperties.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public class RoutingProperties +{ + public RoutingEndpoints? Endpoints { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingTargetEmitted.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingTargetEmitted.cs new file mode 100644 index 000000000..abd8bee6f --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingTargetEmitted.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record RoutingTargetEmitted( + string QueryStatus, + string MetricScope, + string? MetricNamespace, + Dictionary WindowAggregates, + List> Buckets, + List Errors) +{ + public Dictionary MetricAvailability { get; init; } = []; +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingTargetInfo.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingTargetInfo.cs new file mode 100644 index 000000000..61afecd77 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RoutingTargetInfo.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record RoutingTargetInfo( + string ResolutionStatus, + string? ResourceId = null, + string? ResourceType = null, + string? RoutedResourceId = null, + string? ExistenceStatus = null, + string? ExistenceObservedAt = null, + RoutingDiagnosticError? Error = null); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/Routing/IoTHubRoutingEndpointDiagnosticsOptions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Routing/IoTHubRoutingEndpointDiagnosticsOptions.cs new file mode 100644 index 000000000..d5d4570b9 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Routing/IoTHubRoutingEndpointDiagnosticsOptions.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.IoTHub.Options.Routing; + +public sealed class IoTHubRoutingEndpointDiagnosticsOptions : ISubscriptionOption +{ + [Option(Description = "The name of the IoT Hub.")] + public required string HubName { get; set; } + + [Option(Description = "The name of a specific routing endpoint to diagnose. If omitted, all configured routing endpoints are returned.")] + public string? EndpointName { get; set; } + + [Option(Description = "The inclusive observation-window start time in ISO 8601 format. Must be used with --end-time. If neither time is provided, the window defaults to the previous 24 hours.")] + public DateTimeOffset? StartTime { get; set; } + + [Option(Description = "The inclusive observation-window end time in ISO 8601 format. Must be used with --start-time. If neither time is provided, the window defaults to the previous 24 hours.")] + public DateTimeOffset? EndTime { get; set; } + + [Option(Description = "The Azure Monitor bucket size as an ISO 8601 duration; one of PT1M, PT5M, PT15M, PT30M, PT1H, PT6H, PT12H, or P1D. Defaults to PT1H. The selected window and interval cannot exceed 720 buckets.")] + public string? Interval { get; set; } + + [Option(Description = OptionDescriptions.ResourceGroup)] + public required string ResourceGroup { get; set; } + + [Option(Description = OptionDescriptions.Subscription)] + public string? Subscription { get; set; } + + [Option(Description = OptionDescriptions.Tenant)] + public string? Tenant { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/Routing/IoTHubRoutingEndpointHealthOptions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Routing/IoTHubRoutingEndpointHealthOptions.cs new file mode 100644 index 000000000..db3270c1a --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Routing/IoTHubRoutingEndpointHealthOptions.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.IoTHub.Options.Routing; + +public sealed class IoTHubRoutingEndpointHealthOptions : ISubscriptionOption +{ + [Option(Description = "The name of the IoT Hub.")] + public required string HubName { get; set; } + + [Option(Description = "The name of a specific routing endpoint to return. If omitted, all configured routing endpoints are returned.")] + public string? EndpointName { get; set; } + + [Option(Description = OptionDescriptions.ResourceGroup)] + public required string ResourceGroup { get; set; } + + [Option(Description = OptionDescriptions.Subscription)] + public string? Subscription { get; set; } + + [Option(Description = OptionDescriptions.Tenant)] + public string? Tenant { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Routing/MetricFieldNameBuilder.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Routing/MetricFieldNameBuilder.cs new file mode 100644 index 000000000..ea642fc57 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Routing/MetricFieldNameBuilder.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace Azure.Mcp.Tools.IoTHub.Routing; + +internal static class MetricFieldNameBuilder +{ + public static string GetCanonicalIdentity( + string metricNamespace, + string metricName, + string aggregation, + string unit, + IReadOnlyDictionary dimensions) + { + var dimensionIdentity = string.Join( + "|", + dimensions + .OrderBy(entry => entry.Key, StringComparer.Ordinal) + .ThenBy(entry => entry.Value, StringComparer.Ordinal) + .Select(entry => $"{entry.Key}={entry.Value}")); + return $"{metricNamespace}|{metricName}|{aggregation}|{unit}|{dimensionIdentity}"; + } + + public static string GetBaseFieldName( + string metricName, + string aggregation, + string unit, + IReadOnlyDictionary dimensions) + { + var segments = new List { EscapeSegment(metricName) }; + foreach (var (name, value) in dimensions + .OrderBy(entry => GetDimensionOrder(entry.Key)) + .ThenBy(entry => entry.Key, StringComparer.Ordinal)) + { + segments.Add(EscapeSegment(name)); + segments.Add(EscapeSegment(value)); + } + + var aggregationSegment = aggregation switch + { + "Average" => "avg", + "Maximum" => "max", + "Minimum" => "min", + _ => EscapeSegment(aggregation) + }; + var unitSegment = unit switch + { + "Milliseconds" => "ms", + "Percent" => "pct", + _ => EscapeSegment(unit) + }; + + segments.Add(aggregationSegment); + if (!string.Equals(aggregationSegment, unitSegment, StringComparison.Ordinal)) + { + segments.Add(unitSegment); + } + + return string.Join(".", segments); + } + + public static string AppendCollisionHash(string fieldName, string canonicalIdentity) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonicalIdentity)); + return $"{fieldName}~{Convert.ToHexString(hash.AsSpan(0, 4)).ToLowerInvariant()}"; + } + + private static int GetDimensionOrder(string name) => name switch + { + "Result" => 0, + "FailureReasonCategory" => 1, + _ => 2 + }; + + private static string EscapeSegment(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + var lowerCamel = char.ToLowerInvariant(value[0]) + value[1..]; + var bytes = Encoding.UTF8.GetBytes(lowerCamel); + var result = new StringBuilder(bytes.Length); + foreach (var valueByte in bytes) + { + var isAllowed = valueByte is >= (byte)'a' and <= (byte)'z' + or >= (byte)'A' and <= (byte)'Z' + or >= (byte)'0' and <= (byte)'9' + or (byte)'_' + or (byte)'-'; + if (isAllowed) + { + result.Append((char)valueByte); + } + else + { + result.Append('%'); + result.Append(valueByte.ToString("X2", CultureInfo.InvariantCulture)); + } + } + return result.ToString(); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Routing/RoutingDiagnosticsWindow.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Routing/RoutingDiagnosticsWindow.cs new file mode 100644 index 000000000..5f9203a70 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Routing/RoutingDiagnosticsWindow.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Xml; +using Microsoft.Mcp.Core.Commands; + +namespace Azure.Mcp.Tools.IoTHub.Routing; + +// Observation-window rules shared by routing diagnostics option validation and the routing service. +internal static class RoutingDiagnosticsWindow +{ + public static readonly TimeSpan DefaultObservationWindow = TimeSpan.FromHours(24); + public static readonly TimeSpan MaxObservationWindow = TimeSpan.FromDays(30); + public const int MaxMetricBuckets = 720; + + public const string IncompleteTimeRangeError = + "--start-time and --end-time must be provided together."; + + public const string InvalidTimeRangeOrderError = + "--start-time must be earlier than --end-time."; + + public const string TimeRangeTooLargeError = + "The observation window cannot exceed 30 days."; + + public const string InvalidIntervalError = + "--interval must be one of the Azure Monitor supported time grains: PT1M, PT5M, PT15M, PT30M, PT1H, PT6H, PT12H, or P1D."; + + public const string InvalidWindowTelemetryMessage = + "Invalid IoT Hub routing diagnostics time window."; + + // Time grains Azure Monitor accepts for the metrics query; any other value is rejected by the service. + private static readonly TimeSpan[] s_supportedIntervals = + [ + TimeSpan.FromMinutes(1), + TimeSpan.FromMinutes(5), + TimeSpan.FromMinutes(15), + TimeSpan.FromMinutes(30), + TimeSpan.FromHours(1), + TimeSpan.FromHours(6), + TimeSpan.FromHours(12), + TimeSpan.FromDays(1), + ]; + + public static void Validate( + DateTimeOffset? startTime, + DateTimeOffset? endTime, + string? interval, + ValidationResult validationResult) + { + try + { + Resolve(startTime, endTime, interval); + } + catch (ArgumentException ex) + { + validationResult.AddError(ex.Message, InvalidWindowTelemetryMessage); + } + } + + public static (DateTimeOffset StartTime, DateTimeOffset EndTime, TimeSpan Interval) Resolve( + DateTimeOffset? startTime, + DateTimeOffset? endTime, + string? interval, + DateTimeOffset? currentTime = null) + { + if (startTime.HasValue != endTime.HasValue) + { + throw new ArgumentException(IncompleteTimeRangeError); + } + + var end = (endTime ?? currentTime ?? DateTimeOffset.UtcNow).ToUniversalTime(); + var start = (startTime ?? end.Subtract(DefaultObservationWindow)).ToUniversalTime(); + if (start >= end) + { + throw new ArgumentException(InvalidTimeRangeOrderError); + } + + var duration = end - start; + if (duration > MaxObservationWindow) + { + throw new ArgumentException(TimeRangeTooLargeError); + } + + var resolvedInterval = ParseInterval(interval); + var bucketCount = (int)Math.Ceiling(duration.Ticks / (double)resolvedInterval.Ticks); + if (bucketCount > MaxMetricBuckets) + { + var minimumInterval = s_supportedIntervals.First(candidate => + Math.Ceiling(duration.Ticks / (double)candidate.Ticks) <= MaxMetricBuckets); + throw new ArgumentException( + $"The selected window and --interval produce {bucketCount} buckets; the maximum is {MaxMetricBuckets}. Use --interval {XmlConvert.ToString(minimumInterval)} or larger."); + } + + return (start, end, resolvedInterval); + } + + public static TimeSpan ParseInterval(string? interval) + { + if (string.IsNullOrWhiteSpace(interval)) + { + return TimeSpan.FromHours(1); + } + + TimeSpan value; + try + { + value = XmlConvert.ToTimeSpan(interval); + } + catch (FormatException ex) + { + throw new ArgumentException(InvalidIntervalError, ex); + } + + return s_supportedIntervals.Contains(value) + ? value + : throw new ArgumentException(InvalidIntervalError); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Routing/RoutingEndpointMapper.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Routing/RoutingEndpointMapper.cs new file mode 100644 index 000000000..2e9afb7b7 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Routing/RoutingEndpointMapper.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.IoTHub.Models; + +namespace Azure.Mcp.Tools.IoTHub.Routing; + +internal static class RoutingEndpointMapper +{ + internal static List ConvertToRoutingEndpointDetailsList( + RoutingEndpoints? endpoints) + { + if (endpoints is null) + { + return []; + } + + var results = new List(); + AppendEndpoints(results, endpoints.EventHubs, "EventHub", isContainer: false); + AppendEndpoints(results, endpoints.ServiceBusQueues, "ServiceBusQueue", isContainer: false); + AppendEndpoints(results, endpoints.ServiceBusTopics, "ServiceBusTopic", isContainer: false); + AppendEndpoints(results, endpoints.StorageContainers, "StorageContainer", isContainer: true); + AppendEndpoints(results, endpoints.CosmosDBSqlContainers, "CosmosDBSqlContainer", isContainer: true); + return results; + } + + private static void AppendEndpoints( + List results, + List? endpoints, + string endpointType, + bool isContainer) + { + foreach (var endpoint in endpoints ?? []) + { + results.Add(new RoutingEndpointDetails( + Name: endpoint.Name ?? string.Empty, + EndpointType: endpointType, + EndpointResourceName: GetEndpointResourceName(endpoint.EndpointUri), + SubscriptionId: endpoint.SubscriptionId, + ResourceGroup: endpoint.ResourceGroup, + EndpointUri: endpoint.EndpointUri, + EntityPath: isContainer ? null : endpoint.EntityPath, + ContainerName: isContainer ? endpoint.ContainerName : null, + DatabaseName: endpointType == "CosmosDBSqlContainer" ? endpoint.DatabaseName : null, + AuthenticationType: endpoint.AuthenticationType, + BatchFrequencyInSeconds: endpoint.BatchFrequencyInSeconds, + EndpointId: endpoint.Id)); + } + } + + // IoT Hub endpoint ids are opaque and key-based endpoints have no endpointUri, so only the endpoint URI host + // names the target namespace or account. Without it the target stays unresolved instead of being guessed. + private static string? GetEndpointResourceName(string? endpointUri) + { + if (!Uri.TryCreate(endpointUri, UriKind.Absolute, out var uri) || + string.IsNullOrWhiteSpace(uri.Host)) + { + return null; + } + var endpointName = uri.Host.Split('.')[0]; + return string.IsNullOrWhiteSpace(endpointName) ? null : endpointName; + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Routing/RoutingMetricEvidenceBuilder.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Routing/RoutingMetricEvidenceBuilder.cs new file mode 100644 index 000000000..4237c7a36 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Routing/RoutingMetricEvidenceBuilder.cs @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Text.Json; +using Azure.Mcp.Tools.IoTHub.Commands; +using Azure.Mcp.Tools.IoTHub.Models; + +namespace Azure.Mcp.Tools.IoTHub.Routing; + +internal sealed class RoutingMetricEvidenceBuilder(string metricNamespace) +{ + private readonly string _metricNamespace = metricNamespace; + private readonly Dictionary _windowAggregates = new(StringComparer.Ordinal); + private readonly SortedDictionary> _buckets = []; + private readonly Dictionary _fieldIdentities = new(StringComparer.Ordinal); + private readonly Dictionary _metricAvailability = new(StringComparer.Ordinal); + + public bool HasValues => _windowAggregates.Count > 0 || _buckets.Count > 0; + + public bool ContainsMetric(string metricName) => _metricAvailability.ContainsKey(metricName); + + public void MarkSuccessful(string metricName, bool hasValues = false) + { + if (!_metricAvailability.TryGetValue(metricName, out var status) || status == "noValuesReturned") + { + _metricAvailability[metricName] = hasValues ? "valuesReturned" : "noValuesReturned"; + } + else if (hasValues && status == "failed") + { + _metricAvailability[metricName] = "partial"; + } + } + + public void MarkFailed(string metricName) => + _metricAvailability[metricName] = + _metricAvailability.GetValueOrDefault(metricName) is "valuesReturned" or "partial" + ? "partial" + : "failed"; + + public void MarkPartial(string metricName) => _metricAvailability[metricName] = "partial"; + + public void Merge(RoutingMetricEvidence evidence) + { + foreach (var (metricName, status) in evidence.MetricAvailability) + { + _metricAvailability[metricName] = status; + } + + foreach (var (fieldName, value) in evidence.WindowAggregates) + { + if (!_windowAggregates.TryAdd(fieldName, value)) + { + throw new InvalidDataException($"Metric field '{fieldName}' was returned more than once."); + } + } + + foreach (var sourceBucket in evidence.Buckets) + { + var timestamp = DateTimeOffset.Parse( + sourceBucket["timestamp"].GetString()!, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal); + var targetBucket = GetBucket(timestamp); + foreach (var (fieldName, value) in sourceBucket) + { + if (string.Equals(fieldName, "timestamp", StringComparison.Ordinal)) + { + continue; + } + if (!targetBucket.TryAdd(fieldName, value)) + { + throw new InvalidDataException( + $"Metric field '{fieldName}' was returned more than once for bucket '{sourceBucket["timestamp"].GetString()}'."); + } + } + } + } + + public void AddCountSeries( + string metricName, + string aggregation, + IReadOnlyDictionary dimensions, + IEnumerable<(DateTimeOffset Timestamp, double? Value)> points) + { + const string unit = "Count"; + var fieldName = ResolveFieldName(metricName, aggregation, unit, dimensions); + long sum = 0; + var values = new Dictionary(); + foreach (var (timestamp, value) in points) + { + if (!value.HasValue) + { + continue; + } + + var count = ToInt64Count(value.Value, fieldName); + sum = checked(sum + count); + var utcTimestamp = timestamp.ToUniversalTime(); + values[utcTimestamp] = checked(values.GetValueOrDefault(utcTimestamp) + count); + } + + var hasValue = values.Count > 0; + if (hasValue) + { + var current = _windowAggregates.TryGetValue(fieldName, out var existing) + ? existing.GetInt64() + : 0; + var aggregate = checked(current + sum); + foreach (var (timestamp, value) in values) + { + var currentBucketValue = _buckets.TryGetValue(timestamp, out var bucket) && + bucket.TryGetValue(fieldName, out var existingBucketValue) + ? existingBucketValue.GetInt64() : 0; + _ = checked(currentBucketValue + value); + } + foreach (var (timestamp, value) in values) + { + AddCountBucketValue(timestamp, fieldName, value); + } + _windowAggregates[fieldName] = ToJsonElement(aggregate); + } + MarkSuccessful(metricName, hasValue); + } + + public void AddAverageSeries( + string metricName, + string unit, + IReadOnlyDictionary dimensions, + IEnumerable<(DateTimeOffset Timestamp, double? Value)> points) + { + var fieldName = ResolveFieldName(metricName, "Average", unit, dimensions); + var values = points.Where(point => point.Value.HasValue).ToArray(); + foreach (var (_, value) in values) + { + ValidateFinite(value!.Value, fieldName); + } + foreach (var (timestamp, value) in values) + { + AddDoubleBucketValue(timestamp, fieldName, value!.Value); + } + MarkSuccessful(metricName, values.Length > 0); + } + + public void AddMaximumSeries( + string metricName, + string unit, + IReadOnlyDictionary dimensions, + IEnumerable<(DateTimeOffset Timestamp, double? Value)> points) + { + var fieldName = ResolveFieldName(metricName, "Maximum", unit, dimensions); + double? maximum = null; + foreach (var (timestamp, value) in points) + { + if (!value.HasValue) + { + continue; + } + + ValidateFinite(value.Value, fieldName); + AddDoubleBucketValue(timestamp, fieldName, value.Value); + maximum = !maximum.HasValue || value.Value > maximum.Value ? value.Value : maximum; + } + + if (maximum.HasValue) + { + _windowAggregates[fieldName] = ToJsonElement(maximum.Value); + } + MarkSuccessful(metricName, maximum.HasValue); + } + + public RoutingMetricEvidence Build() => new( + _metricNamespace, + new Dictionary(_windowAggregates, StringComparer.Ordinal), + [.. _buckets.Select(entry => + { + var bucket = new Dictionary(StringComparer.Ordinal) + { + ["timestamp"] = JsonSerializer.SerializeToElement( + ToIsoString(entry.Key), + IoTHubJsonContext.Default.String) + }; + foreach (var (fieldName, value) in entry.Value) + { + bucket[fieldName] = value; + } + return bucket; + })]) + { + MetricAvailability = new Dictionary(_metricAvailability, StringComparer.Ordinal) + }; + + private string ResolveFieldName( + string metricName, + string aggregation, + string unit, + IReadOnlyDictionary dimensions) + { + var identity = MetricFieldNameBuilder.GetCanonicalIdentity( + _metricNamespace, + metricName, + aggregation, + unit, + dimensions); + var fieldName = MetricFieldNameBuilder.GetBaseFieldName(metricName, aggregation, unit, dimensions); + if (_fieldIdentities.TryGetValue(fieldName, out var existingIdentity) && + !string.Equals(existingIdentity, identity, StringComparison.Ordinal)) + { + fieldName = MetricFieldNameBuilder.AppendCollisionHash(fieldName, identity); + } + + _fieldIdentities[fieldName] = identity; + return fieldName; + } + + private void AddCountBucketValue(DateTimeOffset timestamp, string fieldName, long value) + { + var bucket = GetBucket(timestamp); + var current = bucket.TryGetValue(fieldName, out var existing) ? existing.GetInt64() : 0; + bucket[fieldName] = ToJsonElement(checked(current + value)); + } + + private void AddDoubleBucketValue(DateTimeOffset timestamp, string fieldName, double value) + { + ValidateFinite(value, fieldName); + GetBucket(timestamp)[fieldName] = ToJsonElement(value); + } + + private Dictionary GetBucket(DateTimeOffset timestamp) + { + var utcTimestamp = timestamp.ToUniversalTime(); + if (!_buckets.TryGetValue(utcTimestamp, out var bucket)) + { + bucket = new Dictionary(StringComparer.Ordinal); + _buckets[utcTimestamp] = bucket; + } + return bucket; + } + + private static long ToInt64Count(double value, string fieldName) + { + ValidateFinite(value, fieldName); + if (value != Math.Truncate(value) || value < long.MinValue || value > long.MaxValue) + { + throw new InvalidDataException( + $"Metric field '{fieldName}' returned a count that cannot be represented as a 64-bit integer."); + } + return checked((long)value); + } + + private static void ValidateFinite(double value, string fieldName) + { + if (!double.IsFinite(value)) + { + throw new InvalidDataException($"Metric field '{fieldName}' returned a non-finite value."); + } + } + + private static JsonElement ToJsonElement(long value) => + JsonSerializer.SerializeToElement(value, IoTHubJsonContext.Default.Int64); + + private static JsonElement ToJsonElement(double value) => + JsonSerializer.SerializeToElement(value, IoTHubJsonContext.Default.Double); + + private static string ToIsoString(DateTimeOffset value) => + value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ", CultureInfo.InvariantCulture); +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Services/ArmGetResult.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Services/ArmGetResult.cs new file mode 100644 index 000000000..fd72b2bbf --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Services/ArmGetResult.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; + +namespace Azure.Mcp.Tools.IoTHub.Services; + +internal sealed record ArmGetResult(HttpStatusCode StatusCode, string Content) +{ + public bool IsSuccess => (int)StatusCode is >= 200 and < 300; +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Services/ExistenceResult.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Services/ExistenceResult.cs new file mode 100644 index 000000000..fecd96a00 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Services/ExistenceResult.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.IoTHub.Models; + +namespace Azure.Mcp.Tools.IoTHub.Services; + +internal sealed record ExistenceResult( + string Status, + DateTimeOffset ObservedAt, + RoutingDiagnosticError? Error); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Services/IIoTHubRoutingService.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Services/IIoTHubRoutingService.cs new file mode 100644 index 000000000..2d8d37072 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Services/IIoTHubRoutingService.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.IoTHub.Models; + +namespace Azure.Mcp.Tools.IoTHub.Services; + +public interface IIoTHubRoutingService +{ + Task> GetRoutingEndpointHealth( + string hubName, + string resourceGroup, + string subscription, + string? endpointName = null, + string? tenant = null, + CancellationToken cancellationToken = default); + + Task GetRoutingEndpointDiagnostics( + string hubName, + string resourceGroup, + string subscription, + string? endpointName = null, + DateTimeOffset? startTime = null, + DateTimeOffset? endTime = null, + string? interval = null, + string? tenant = null, + CancellationToken cancellationToken = default); +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Services/IoTHubRoutingService.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Services/IoTHubRoutingService.cs new file mode 100644 index 000000000..b5ad097ca --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Services/IoTHubRoutingService.cs @@ -0,0 +1,1218 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net; +using System.Text.Json; +using System.Xml; +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Tools.IoTHub.Commands; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Routing; +using Azure.ResourceManager; +using Azure.ResourceManager.Monitor; +using Azure.ResourceManager.Monitor.Models; +using Azure.ResourceManager.Resources; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Tools.IoTHub.Services; + +public class IoTHubRoutingService( + IAzureService azureService, + IHttpClientFactory httpClientFactory, + ILogger logger) + : BaseAzureService(azureService), IIoTHubRoutingService +{ + private readonly IHttpClientFactory _httpClientFactory = + httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + private readonly ILogger _logger = + logger ?? throw new ArgumentNullException(nameof(logger)); + + private static readonly TimeSpan s_operationTimeout = TimeSpan.FromSeconds(100); + + internal static async Task ExecuteWithTimeoutAsync( + Func> operation, + string operationName, + CancellationToken cancellationToken, + TimeSpan? timeout = null) + { + var operationTimeout = timeout ?? s_operationTimeout; + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(operationTimeout); + try + { + timeoutCts.Token.ThrowIfCancellationRequested(); + return await operation(timeoutCts.Token); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"The IoT Hub '{operationName}' operation timed out after {operationTimeout.TotalSeconds:N0} seconds."); + } + } + + public async Task> GetRoutingEndpointHealth( + string hubName, + string resourceGroup, + string subscription, + string? endpointName = null, + string? tenant = null, + CancellationToken cancellationToken = default) + { + ValidateInputs(hubName, resourceGroup, subscription); + + try + { + return await ExecuteWithTimeoutAsync>(async ct => + { + var hub = await GetIoTHubGenericResourceAsync( + hubName, + resourceGroup, + subscription, + tenant, + ct); + var properties = hub.Properties?.ToObjectFromJson(IoTHubJsonContext.Default.IoTHubProperties); + var endpoints = FilterEndpoints( + RoutingEndpointMapper.ConvertToRoutingEndpointDetailsList(properties?.Routing?.Endpoints), + endpointName, + hubName); + var endpointNames = endpoints + .Where(endpoint => !string.IsNullOrWhiteSpace(endpoint.EndpointId)) + .ToDictionary( + endpoint => endpoint.EndpointId!, + endpoint => endpoint.Name, + StringComparer.OrdinalIgnoreCase); + var healthRecords = await GetRoutingEndpointsHealthAsync( + hub.Id, + tenant, + ct); + + return + [ + .. healthRecords + .Where(record => + !string.IsNullOrWhiteSpace(record.EndpointId) && + endpointNames.ContainsKey(record.EndpointId)) + .Select(record => new RoutingEndpointHealthSnapshot( + EndpointId: record.EndpointId!, + EndpointName: endpointNames[record.EndpointId!], + HealthStatus: record.HealthStatus, + LastKnownError: record.LastKnownError, + LastKnownErrorTime: record.LastKnownErrorTime, + LastSuccessfulSendAttemptTime: record.LastSuccessfulSendAttemptTime, + LastSendAttemptTime: record.LastSendAttemptTime)) + ]; + }, "routing endpoint health", cancellationToken); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error retrieving routing endpoint health for IoT Hub '{HubName}' in resource group '{ResourceGroup}' and subscription '{Subscription}'.", + hubName, + resourceGroup, + subscription); + throw; + } + } + + public async Task GetRoutingEndpointDiagnostics( + string hubName, + string resourceGroup, + string subscription, + string? endpointName = null, + DateTimeOffset? startTime = null, + DateTimeOffset? endTime = null, + string? interval = null, + string? tenant = null, + CancellationToken cancellationToken = default) + { + ValidateInputs(hubName, resourceGroup, subscription); + var resolvedWindow = RoutingDiagnosticsWindow.Resolve(startTime, endTime, interval); + var window = new ObservationWindow(resolvedWindow.StartTime, resolvedWindow.EndTime); + + try + { + return await ExecuteWithTimeoutAsync(async ct => + { + var hub = await GetIoTHubGenericResourceAsync( + hubName, + resourceGroup, + subscription, + tenant, + ct); + var properties = hub.Properties?.ToObjectFromJson(IoTHubJsonContext.Default.IoTHubProperties); + var endpoints = FilterEndpoints( + RoutingEndpointMapper.ConvertToRoutingEndpointDetailsList(properties?.Routing?.Endpoints), + endpointName, + hubName); + var hubEvidence = await QueryHubEvidenceAsync( + hub.Id, + endpoints, + window, + resolvedWindow.Interval, + tenant, + ct); + var targetEvidence = new ConcurrentDictionary>>( + StringComparer.OrdinalIgnoreCase); + + var endpointResults = await Task.WhenAll(endpoints.Select(endpoint => + BuildEndpointDiagnosticsAsync( + endpoint, + hubEvidence.GetValueOrDefault(endpoint.Name) ?? CreateEmptyHubEvidence(), + window, + resolvedWindow.Interval, + tenant, + targetEvidence, + ct))); + + return new RoutingEndpointDiagnostics( + new RoutingObservationWindow( + ToIsoString(window.StartTime), + ToIsoString(window.EndTime), + XmlConvert.ToString(resolvedWindow.Interval)), + [.. endpointResults]); + }, "routing endpoint diagnostics", cancellationToken); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error retrieving routing endpoint diagnostics for IoT Hub '{HubName}' in resource group '{ResourceGroup}' and subscription '{Subscription}'.", + hubName, + resourceGroup, + subscription); + throw; + } + } + + private async Task BuildEndpointDiagnosticsAsync( + RoutingEndpointDetails endpoint, + RoutingHubEmitted hubEvidence, + ObservationWindow window, + TimeSpan interval, + string? tenant, + ConcurrentDictionary>> targetEvidence, + CancellationToken cancellationToken) + { + var resolution = ResolveTarget(endpoint); + if (resolution is null) + { + var resolutionStatus = GetTargetDescriptor(endpoint.EndpointType) is null + ? "unsupported" + : "unresolved"; + return CreateEndpointDiagnostic( + endpoint, + new RoutingTargetInfo(resolutionStatus, ExistenceStatus: "notChecked"), + hubEvidence, + CreateEmptyTargetEvidence(resolutionStatus)); + } + + var existenceTask = GetTargetExistenceAsync( + resolution.RoutedResourceId, + resolution.Descriptor.ApiVersion, + endpoint.Name, + tenant, + cancellationToken); + var evidenceTask = targetEvidence.GetOrAdd( + resolution.ParentResourceId.ToString(), + _ => new Lazy>( + () => QueryTargetEvidenceAsync( + resolution.ParentResourceId, + resolution.Descriptor, + window, + interval, + tenant, + cancellationToken))).Value; + + await Task.WhenAll(existenceTask, evidenceTask); + var existence = await existenceTask; + var targetMetrics = await evidenceTask; + var target = new RoutingTargetInfo( + "resolved", + resolution.ParentResourceId.ToString(), + resolution.Descriptor.ResourceType, + resolution.RoutedResourceId.ToString(), + existence.Status, + ToIsoString(existence.ObservedAt), + existence.Error); + return CreateEndpointDiagnostic(endpoint, target, hubEvidence, targetMetrics); + } + + private static RoutingEndpointDiagnostic CreateEndpointDiagnostic( + RoutingEndpointDetails endpoint, + RoutingTargetInfo target, + RoutingHubEmitted hubEvidence, + RoutingTargetEmitted targetEvidence) => new( + EndpointId: endpoint.EndpointId ?? string.Empty, + Name: endpoint.Name, + EndpointType: endpoint.EndpointType, + AuthenticationType: endpoint.AuthenticationType, + EndpointUri: endpoint.EndpointUri, + SubscriptionId: endpoint.SubscriptionId, + ResourceGroup: endpoint.ResourceGroup, + EndpointResourceName: endpoint.EndpointResourceName, + EntityPath: endpoint.EntityPath, + ContainerName: endpoint.ContainerName, + DatabaseName: endpoint.DatabaseName, + BatchFrequencyInSeconds: endpoint.BatchFrequencyInSeconds, + Target: target, + HubEmitted: hubEvidence, + TargetEmitted: targetEvidence); + + private async Task> QueryHubEvidenceAsync( + ResourceIdentifier hubResourceId, + IReadOnlyList endpoints, + ObservationWindow window, + TimeSpan interval, + string? tenant, + CancellationToken cancellationToken) + { + const string metricNamespace = "Microsoft.Devices/IotHubs"; + var builders = endpoints.ToDictionary( + endpoint => endpoint.Name, + _ => new RoutingMetricEvidenceBuilder(metricNamespace), + StringComparer.OrdinalIgnoreCase); + var errors = endpoints.ToDictionary( + endpoint => endpoint.Name, + _ => new List(), + StringComparer.OrdinalIgnoreCase); + if (endpoints.Count == 0) + { + return []; + } + + var armClient = await CreateRoutingArmClientAsync(tenant, cancellationToken); + var endpointFilter = BuildEndpointFilter(endpoints); + foreach (var metricName in new[] { "RoutingDeliveries", "RoutingDeliveryLatency" }) + { + var isDeliveryCount = metricName == "RoutingDeliveries"; + try + { + var options = BuildMonitorMetricsOptions( + metricName, + metricNamespace, + window, + interval, + isDeliveryCount ? "Total" : "Average", + isDeliveryCount + ? $"{endpointFilter} and Result eq '*' and FailureReasonCategory eq '*'" + : endpointFilter); + var metrics = armClient.GetMonitorMetricsAsync(hubResourceId, options, cancellationToken); + var metricReturned = false; + await foreach (var metric in metrics.WithCancellation(cancellationToken)) + { + if (!string.Equals(metric.Name?.Value, metricName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + metricReturned = true; + var metricError = GetMetricResponseError( + metric, metricName, hubResourceId, "hubAzureMonitor", "Reader"); + if (metricError is not null) + { + AddSharedError(errors, metricError); + foreach (var builder in builders.Values) + { + builder.MarkFailed(metricName); + } + continue; + } + + foreach (var series in metric.Timeseries) + { + cancellationToken.ThrowIfCancellationRequested(); + var endpointName = GetDimensionValue(series, "EndpointName"); + if (string.IsNullOrWhiteSpace(endpointName) || + !builders.TryGetValue(endpointName, out var builder)) + { + continue; + } + + try + { + if (isDeliveryCount) + { + var dimensions = new Dictionary(StringComparer.Ordinal); + AddDimensionIfPresent(series, dimensions, "Result"); + AddDimensionIfPresent(series, dimensions, "FailureReasonCategory"); + builder.AddCountSeries( + metricName, + "Total", + dimensions, + series.Data.Select(point => (point.TimeStamp, point.Total ?? point.Count))); + } + else + { + builder.AddAverageSeries( + metricName, + "Milliseconds", + EmptyDimensions, + series.Data.Select(point => (point.TimeStamp, point.Average))); + } + } + catch (Exception ex) when (ex is InvalidDataException or OverflowException) + { + builder.MarkFailed(metricName); + errors[endpointName].Add(CreateDiagnosticError( + ex, "hubAzureMonitor", metricName, hubResourceId.ToString(), "Reader", + "IoT Hub routing metrics could not be represented.")); + } + } + + if (metric.Timeseries.Count >= MetricSeriesLimit) + { + AddSharedError(errors, CreateSeriesLimitError( + metricName, hubResourceId, "hubAzureMonitor")); + foreach (var builder in builders.Values) + { + builder.MarkPartial(metricName); + } + } + } + + if (!metricReturned) + { + AddSharedError(errors, CreateMissingMetricError(metricName, hubResourceId, "hubAzureMonitor")); + } + foreach (var builder in builders.Values) + { + if (metricReturned) + { + builder.MarkSuccessful(metricName); + } + else + { + builder.MarkFailed(metricName); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && !cancellationToken.IsCancellationRequested) + { + _logger.LogWarning(ex, "Unable to query '{MetricName}' for IoT Hub '{HubResourceId}'.", + metricName, hubResourceId); + foreach (var builder in builders.Values) + { + builder.MarkFailed(metricName); + } + AddSharedError(errors, CreateDiagnosticError( + ex, "hubAzureMonitor", metricName, hubResourceId.ToString(), "Reader", + "IoT Hub routing metrics could not be read.")); + } + } + + return endpoints.ToDictionary( + endpoint => endpoint.Name, + endpoint => new RoutingHubEmitted( + builders[endpoint.Name].Build(), + errors[endpoint.Name]), + StringComparer.OrdinalIgnoreCase); + } + + private async Task QueryTargetEvidenceAsync( + ResourceIdentifier resourceId, + RoutingTargetDescriptor descriptor, + ObservationWindow window, + TimeSpan interval, + string? tenant, + CancellationToken cancellationToken) + { + var builder = new RoutingMetricEvidenceBuilder(descriptor.ResourceType); + var errors = new List(); + var armClient = await CreateRoutingArmClientAsync(tenant, cancellationToken); + + switch (descriptor.EndpointType) + { + case "EventHub": + await QueryMetricSetWithFallbackAsync( + resourceId, + descriptor.ResourceType, + ["SuccessfulRequests", "ServerErrors", "UserErrors", "ThrottledRequests", "QuotaExceededErrors"], + "Total", + "Count", + null, + window, + interval, + armClient, + builder, + errors, + cancellationToken); + break; + case "ServiceBusQueue": + case "ServiceBusTopic": + await QueryMetricSetWithFallbackAsync( + resourceId, + descriptor.ResourceType, + ["IncomingMessages", "IncomingRequests", "ServerErrors", "UserErrors", "ThrottledRequests"], + "Total", + "Count", + null, + window, + interval, + armClient, + builder, + errors, + cancellationToken); + break; + case "StorageContainer": + await QueryMetricSetWithFallbackAsync( + resourceId, + descriptor.ResourceType, + ["Transactions"], + "Total", + "Count", + "ResponseType eq '*'", + window, + interval, + armClient, + builder, + errors, + cancellationToken); + break; + case "CosmosDBSqlContainer": + await QueryMetricSetWithFallbackAsync( + resourceId, + descriptor.ResourceType, + ["TotalRequests"], + "Count", + "Count", + "StatusCode eq '*'", + window, + interval, + armClient, + builder, + errors, + cancellationToken); + await QueryMetricSetWithFallbackAsync( + resourceId, + descriptor.ResourceType, + ["NormalizedRUConsumption"], + "Maximum", + "Percent", + null, + window, + interval, + armClient, + builder, + errors, + cancellationToken); + break; + } + + var evidence = builder.Build(); + return new RoutingTargetEmitted( + GetQueryStatus(evidence.MetricAvailability.Values.Any(status => status != "failed"), errors), + "parentResource", + descriptor.ResourceType, + evidence.WindowAggregates, + evidence.Buckets, + errors) + { + MetricAvailability = evidence.MetricAvailability + }; + } + + private async Task QueryMetricSetWithFallbackAsync( + ResourceIdentifier resourceId, + string resourceType, + string[] metricNames, + string aggregation, + string unit, + string? filter, + ObservationWindow window, + TimeSpan interval, + ArmClient armClient, + RoutingMetricEvidenceBuilder destination, + List errors, + CancellationToken cancellationToken) + { + try + { + await QueryMetricSetCoreAsync( + resourceId, + resourceType, + metricNames, + aggregation, + unit, + filter, + window, + interval, + armClient, + destination, + errors, + cancellationToken); + return; + } + // Azure Monitor rejects a whole batch with 400 when one metric is unavailable for the resource. + // Throttling and server errors must not fan out into per-metric requests. + catch (RequestFailedException ex) when ( + ex.Status == (int)HttpStatusCode.BadRequest && + metricNames.Length > 1 && + !cancellationToken.IsCancellationRequested) + { + _logger.LogWarning( + ex, + "Batched target metric query failed for '{ResourceId}'; retrying metrics individually.", + resourceId); + } + catch (Exception ex) when (ex is not OperationCanceledException && !cancellationToken.IsCancellationRequested) + { + foreach (var metricName in metricNames.Where(name => !destination.ContainsMetric(name))) + { + destination.MarkFailed(metricName); + errors.Add(CreateTargetMetricError(ex, resourceId, metricName)); + } + return; + } + + foreach (var metricName in metricNames.Where(name => !destination.ContainsMetric(name))) + { + try + { + await QueryMetricSetCoreAsync( + resourceId, + resourceType, + [metricName], + aggregation, + unit, + filter, + window, + interval, + armClient, + destination, + errors, + cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException && !cancellationToken.IsCancellationRequested) + { + _logger.LogWarning( + ex, + "Target metric '{MetricName}' is unavailable for '{ResourceId}'.", + metricName, + resourceId); + errors.Add(CreateTargetMetricError(ex, resourceId, metricName)); + destination.MarkFailed(metricName); + } + } + } + + private static async Task QueryMetricSetCoreAsync( + ResourceIdentifier resourceId, + string resourceType, + string[] metricNames, + string aggregation, + string unit, + string? filter, + ObservationWindow window, + TimeSpan interval, + ArmClient armClient, + RoutingMetricEvidenceBuilder builder, + List errors, + CancellationToken cancellationToken) + { + var options = BuildMonitorMetricsOptions( + string.Join(",", metricNames), + resourceType, + window, + interval, + aggregation, + filter); + var metrics = armClient.GetMonitorMetricsAsync(resourceId, options, cancellationToken); + var returnedMetricNames = new HashSet(StringComparer.OrdinalIgnoreCase); + await foreach (var metric in metrics.WithCancellation(cancellationToken)) + { + var metricName = metricNames.FirstOrDefault(name => + string.Equals(name, metric.Name?.Value, StringComparison.OrdinalIgnoreCase)); + if (metricName is null) + { + continue; + } + returnedMetricNames.Add(metricName); + var metricError = GetMetricResponseError( + metric, metricName, resourceId, "targetAzureMonitor", "Monitoring Reader"); + if (metricError is not null) + { + builder.MarkFailed(metricName); + errors.Add(metricError); + continue; + } + + var metricBuilder = new RoutingMetricEvidenceBuilder(resourceType); + try + { + foreach (var series in metric.Timeseries) + { + cancellationToken.ThrowIfCancellationRequested(); + var dimensions = GetDimensions(series); + switch (aggregation) + { + case "Total": + metricBuilder.AddCountSeries( + metricName, + aggregation, + dimensions, + series.Data.Select(point => (point.TimeStamp, point.Total ?? point.Count))); + break; + case "Count": + metricBuilder.AddCountSeries( + metricName, + aggregation, + dimensions, + series.Data.Select(point => (point.TimeStamp, point.Count ?? point.Total))); + break; + case "Maximum": + metricBuilder.AddMaximumSeries( + metricName, + unit, + dimensions, + series.Data.Select(point => (point.TimeStamp, point.Maximum))); + break; + default: + throw new InvalidOperationException( + $"Unsupported target metric aggregation '{aggregation}'."); + } + } + metricBuilder.MarkSuccessful(metricName); + if (metric.Timeseries.Count >= MetricSeriesLimit) + { + metricBuilder.MarkPartial(metricName); + errors.Add(CreateSeriesLimitError(metricName, resourceId, "targetAzureMonitor")); + } + builder.Merge(metricBuilder.Build()); + } + catch (Exception ex) when (ex is InvalidDataException or OverflowException) + { + builder.MarkFailed(metricName); + errors.Add(CreateTargetMetricError(ex, resourceId, metricName)); + } + } + foreach (var metricName in metricNames.Where(name => !returnedMetricNames.Contains(name))) + { + builder.MarkFailed(metricName); + errors.Add(CreateMissingMetricError(metricName, resourceId, "targetAzureMonitor")); + } + } + + // Monitor defaults to ten series for filtered queries; its REST/SDK top parameter is an Int32. + internal const int MetricSeriesLimit = 10000; + + internal static ArmResourceGetMonitorMetricsOptions BuildMonitorMetricsOptions( + string metricNames, + string resourceType, + ObservationWindow window, + TimeSpan interval, + string aggregation, + string? filter = null) + { + var options = new ArmResourceGetMonitorMetricsOptions + { + Metricnames = metricNames, + Metricnamespace = resourceType, + Timespan = window.Timespan, + Interval = interval, + Aggregation = aggregation + }; + if (!string.IsNullOrEmpty(filter)) + { + options.Filter = filter; + options.Top = MetricSeriesLimit; + } + return options; + } + + private async Task CreateRoutingArmClientAsync(string? tenant, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + return await CreateArmClientAsync(tenant, cancellationToken: cancellationToken); + } + catch (Exception) when (cancellationToken.IsCancellationRequested) + { + // The shared ARM client factory can wrap credential cancellation in a general exception. + cancellationToken.ThrowIfCancellationRequested(); + throw; + } + } + + private static string BuildEndpointFilter(IReadOnlyList endpoints) + { + var filter = string.Join(" or ", endpoints.Select(endpoint => + $"EndpointName eq '{endpoint.Name.Replace("'", "''", StringComparison.Ordinal)}'")); + return endpoints.Count == 1 ? filter : $"({filter})"; + } + + private static RoutingDiagnosticError? GetMetricResponseError( + MonitorMetric metric, + string metricName, + ResourceIdentifier resourceId, + string source, + string requiredRole) => + // Provider messages can contain resource configuration; retain the code without echoing raw text. + (!string.IsNullOrWhiteSpace(metric.ErrorCode) && + !string.Equals(metric.ErrorCode, "Success", StringComparison.OrdinalIgnoreCase)) || + !string.IsNullOrWhiteSpace(metric.ErrorMessage) + ? new RoutingDiagnosticError( + source, metricName, resourceId.ToString(), 200, + string.IsNullOrWhiteSpace(metric.ErrorCode) || + string.Equals(metric.ErrorCode, "Success", StringComparison.OrdinalIgnoreCase) + ? "MetricQueryFailed" : metric.ErrorCode, + requiredRole, + $"Azure Monitor reported an error for metric '{metricName}' in a successful HTTP response.") + : null; + + private static RoutingDiagnosticError CreateSeriesLimitError( + string metricName, + ResourceIdentifier resourceId, + string source) => new( + source, metricName, resourceId.ToString(), 200, "PossibleTruncation", null, + $"Metric '{metricName}' reached the requested limit of {MetricSeriesLimit} time series. " + + "Returned buckets and window aggregates may be incomplete; missing values must not be treated as zero."); + + private static RoutingDiagnosticError CreateMissingMetricError( + string metricName, + ResourceIdentifier resourceId, + string source) => new( + source, metricName, resourceId.ToString(), 200, "MissingMetricResponse", null, + $"Azure Monitor did not return the requested metric '{metricName}'. " + + "Its availability cannot be determined from this response."); + + private async Task GetTargetExistenceAsync( + ResourceIdentifier resourceId, + string apiVersion, + string endpointName, + string? tenant, + CancellationToken cancellationToken) + { + try + { + var response = await GetArmAsync( + resourceId.ToString(), + apiVersion, + tenant, + cancellationToken); + var observedAt = DateTimeOffset.UtcNow; + if (response.IsSuccess) + { + return new ExistenceResult("exists", observedAt, null); + } + if (IsResourceNotFoundResponse(response.StatusCode, response.Content)) + { + return new ExistenceResult("notFound", observedAt, null); + } + + return new ExistenceResult( + "indeterminate", + observedAt, + new RoutingDiagnosticError( + "targetArm", + "read", + resourceId.ToString(), + (int)response.StatusCode, + GetArmErrorCode(response.Content) ?? "Indeterminate", + "Reader", + "The current target resource existence could not be determined.")); + } + catch (Exception ex) when (ex is not OperationCanceledException && !cancellationToken.IsCancellationRequested) + { + _logger.LogWarning( + ex, + "Unable to check routed resource existence for endpoint '{EndpointName}'.", + endpointName); + return new ExistenceResult( + "indeterminate", + DateTimeOffset.UtcNow, + CreateDiagnosticError( + ex, + "targetArm", + "read", + resourceId.ToString(), + "Reader", + "The current target resource existence could not be determined.")); + } + } + + internal async Task> GetRoutingEndpointsHealthAsync( + ResourceIdentifier hubResourceId, + string? tenant, + CancellationToken cancellationToken) + { + var results = new List(); + string? nextLink = $"{hubResourceId}/routingEndpointsHealth"; + string? apiVersion = "2023-06-30"; + while (!string.IsNullOrEmpty(nextLink)) + { + var response = await GetArmAsync(nextLink, apiVersion, tenant, cancellationToken); + if (!response.IsSuccess) + { + throw new RequestFailedException( + (int)response.StatusCode, + "IoT Hub routing endpoint health could not be read."); + } + + var page = JsonSerializer.Deserialize( + response.Content, + IoTHubJsonContext.Default.EndpointHealthDataListResult); + results.AddRange(page?.Value ?? []); + nextLink = page?.NextLink; + apiVersion = null; + } + return results; + } + + private async Task GetArmAsync( + string resourcePathOrUrl, + string? apiVersion, + string? tenant, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + // Reject untrusted destinations, including ARM continuation links, before any credential work. + var requestUri = BuildArmRequestUri( + AzureService, + resourcePathOrUrl, + apiVersion); + TokenCredential credential; + try + { + var tenantId = string.IsNullOrEmpty(tenant) + ? null + : await AzureService.ResolveTenantIdAsync(tenant, cancellationToken); + credential = await AzureService.GetTokenCredentialAsync(tenantId, cancellationToken); + } + catch (Exception) when (cancellationToken.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + throw; + } + + using var httpClient = _httpClientFactory.CreateClient(); + using var transport = new HttpClientTransport(httpClient); + var options = AddDefaultPolicies(new ArmClientOptions()); + options.Transport = transport; + options.AddPolicy( + new BearerTokenAuthenticationPolicy( + credential, AzureService.CloudConfiguration.ArmEnvironment.DefaultScope), + HttpPipelinePosition.PerRetry); + var pipeline = HttpPipelineBuilder.Build(options); + using var request = pipeline.CreateRequest(); + request.Method = RequestMethod.Get; + request.Uri.Reset(requestUri); + request.Headers.Add("Accept", "application/json"); + using var response = await pipeline.SendRequestAsync(request, cancellationToken); + return new ArmGetResult( + (HttpStatusCode)response.Status, + response.Content.ToString()); + } + + internal static Uri BuildArmRequestUri( + IAzureService azureService, + string resourcePathOrNextLink, + string? apiVersion) + { + // Classify rooted ARM paths first: on Linux and macOS, Uri.TryCreate parses "/subscriptions/..." + // as an absolute file:// URI. + var url = resourcePathOrNextLink.StartsWith('/') + ? $"{azureService.CloudConfiguration.ArmEnvironment.Endpoint.AbsoluteUri.TrimEnd('/')}{resourcePathOrNextLink}" + : resourcePathOrNextLink; + if (!string.IsNullOrEmpty(apiVersion)) + { + url += $"{(url.Contains('?') ? '&' : '?')}api-version={apiVersion}"; + } + + // Initial requests and ARM continuation links must both target the configured cloud's ARM host + // before the bearer token is attached. + azureService.ValidateAzureServiceEndpoint( + endpoint: url, + serviceType: "arm"); + return new Uri(url); + } + + internal static bool IsResourceNotFoundResponse( + HttpStatusCode statusCode, + string? responseContent) + { + if (statusCode == HttpStatusCode.NotFound) + { + return true; + } + if (string.IsNullOrWhiteSpace(responseContent)) + { + return false; + } + + try + { + return IsResourceNotFoundErrorCode(GetArmErrorCode(responseContent)); + } + catch (JsonException) + { + return false; + } + } + + private static string? GetArmErrorCode(string? responseContent) + { + if (string.IsNullOrWhiteSpace(responseContent)) + { + return null; + } + + using var document = JsonDocument.Parse(responseContent); + return document.RootElement.TryGetProperty("error", out var error) + && error.TryGetProperty("code", out var code) + ? code.GetString() + : null; + } + + private static bool IsResourceNotFoundErrorCode(string? errorCode) => + errorCode is not null + && (errorCode.Equals("ResourceNotFound", StringComparison.OrdinalIgnoreCase) + || errorCode.Equals("ParentResourceNotFound", StringComparison.OrdinalIgnoreCase) + || errorCode.Equals("EntityNotFound", StringComparison.OrdinalIgnoreCase) + || errorCode.Equals("MessagingEntityNotFound", StringComparison.OrdinalIgnoreCase) + || errorCode.Equals("ContainerNotFound", StringComparison.OrdinalIgnoreCase) + || errorCode.Equals("NotFound", StringComparison.OrdinalIgnoreCase)); + + private async Task GetIoTHubGenericResourceAsync( + string hubName, + string resourceGroup, + string subscription, + string? tenant, + CancellationToken cancellationToken) + { + try + { + var subscriptionResource = await AzureService.GetSubscription( + subscription, + tenant, + cancellationToken: cancellationToken); + var armClient = await CreateRoutingArmClientAsync(tenant, cancellationToken); + var resourceId = BuildResourceId( + subscriptionResource.Data.SubscriptionId, + resourceGroup, + "Microsoft.Devices/IotHubs", + hubName); + var hub = await armClient.GetGenericResource(resourceId).GetAsync(cancellationToken); + return hub.Value.Data; + } + catch (Exception) when (cancellationToken.IsCancellationRequested) + { + // Subscription resolution can also wrap cancellation from its ARM client factory. + cancellationToken.ThrowIfCancellationRequested(); + throw; + } + } + + private static List FilterEndpoints( + List endpoints, + string? endpointName, + string hubName) + { + if (string.IsNullOrWhiteSpace(endpointName)) + { + return endpoints; + } + + var filtered = endpoints + .Where(endpoint => + string.Equals(endpoint.Name, endpointName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + return filtered.Count > 0 + ? filtered + : throw new KeyNotFoundException( + $"Routing endpoint '{endpointName}' not found on IoT Hub '{hubName}'."); + } + + private static ResolvedTarget? ResolveTarget(RoutingEndpointDetails endpoint) + { + var descriptor = GetTargetDescriptor(endpoint.EndpointType); + if (descriptor is null || + !Guid.TryParse(endpoint.SubscriptionId, out _) || + !IoTHubValidation.IsValidResourceGroupName(endpoint.ResourceGroup) || + !IsResourceNameSegment(endpoint.EndpointResourceName)) + { + return null; + } + + var parent = BuildResourceId( + endpoint.SubscriptionId, + endpoint.ResourceGroup, + descriptor.ResourceType, + endpoint.EndpointResourceName); + ResourceIdentifier? routedResource = endpoint.EndpointType switch + { + "EventHub" when IsResourceNameSegment(endpoint.EntityPath) + => new ResourceIdentifier($"{parent}/eventhubs/{endpoint.EntityPath}"), + "ServiceBusQueue" when IsResourceNameSegment(endpoint.EntityPath) + => new ResourceIdentifier($"{parent}/queues/{endpoint.EntityPath}"), + "ServiceBusTopic" when IsResourceNameSegment(endpoint.EntityPath) + => new ResourceIdentifier($"{parent}/topics/{endpoint.EntityPath}"), + "StorageContainer" when IsResourceNameSegment(endpoint.ContainerName) + => new ResourceIdentifier( + $"{parent}/blobServices/default/containers/{endpoint.ContainerName}"), + "CosmosDBSqlContainer" when + IsResourceNameSegment(endpoint.DatabaseName) && + IsResourceNameSegment(endpoint.ContainerName) + => new ResourceIdentifier( + $"{parent}/sqlDatabases/{endpoint.DatabaseName}/containers/{endpoint.ContainerName}"), + _ => null + }; + return routedResource is null ? null : new ResolvedTarget(descriptor, parent, routedResource); + } + + // Endpoint values from the hub configuration are interpolated into ARM resource IDs. A name that is not a single + // literal path segment could make URI normalization target a different resource, so the target stays unresolved. + private static bool IsResourceNameSegment([NotNullWhen(true)] string? value) => + !string.IsNullOrWhiteSpace(value) && + value is not ("." or "..") && + value.AsSpan().IndexOfAny("/\\?#%") < 0; + + private static RoutingTargetDescriptor? GetTargetDescriptor(string endpointType) => endpointType switch + { + "EventHub" => new("EventHub", "Microsoft.EventHub/namespaces", "2024-01-01"), + "ServiceBusQueue" => new("ServiceBusQueue", "Microsoft.ServiceBus/namespaces", "2024-01-01"), + "ServiceBusTopic" => new("ServiceBusTopic", "Microsoft.ServiceBus/namespaces", "2024-01-01"), + "StorageContainer" => new("StorageContainer", "Microsoft.Storage/storageAccounts", "2023-05-01"), + "CosmosDBSqlContainer" => new("CosmosDBSqlContainer", "Microsoft.DocumentDB/databaseAccounts", "2024-05-15"), + _ => null + }; + + private static IReadOnlyDictionary GetDimensions( + MonitorTimeSeriesElement series) + { + var dimensions = new Dictionary(StringComparer.Ordinal); + foreach (var metadata in series.Metadatavalues) + { + var name = metadata.Name?.Value; + if (!string.IsNullOrWhiteSpace(name) && + !string.IsNullOrWhiteSpace(metadata.Value) && + !string.Equals(metadata.Value, "__Empty", StringComparison.OrdinalIgnoreCase)) + { + dimensions[name] = metadata.Value; + } + } + return dimensions; + } + + private static string? GetDimensionValue(MonitorTimeSeriesElement series, string name) => + series.Metadatavalues + .FirstOrDefault(metadata => + string.Equals(metadata.Name?.Value, name, StringComparison.OrdinalIgnoreCase)) + ?.Value; + + private static void AddDimensionIfPresent( + MonitorTimeSeriesElement series, + Dictionary dimensions, + string name) + { + var value = GetDimensionValue(series, name); + if (!string.IsNullOrWhiteSpace(value) && + !string.Equals(value, "__Empty", StringComparison.OrdinalIgnoreCase)) + { + dimensions[name] = value; + } + } + + private static void AddSharedError( + Dictionary> errors, + RoutingDiagnosticError error) + { + foreach (var endpointErrors in errors.Values) + { + endpointErrors.Add(error); + } + } + + private static RoutingDiagnosticError CreateTargetMetricError( + Exception exception, + ResourceIdentifier resourceId, + string metricName) => CreateDiagnosticError( + exception, + "targetAzureMonitor", + metricName, + resourceId.ToString(), + "Monitoring Reader", + $"Target Azure Monitor metric '{metricName}' could not be read."); + + private static RoutingDiagnosticError CreateDiagnosticError( + Exception exception, + string source, + string operation, + string? resourceId, + string? requiredRole, + string message) + { + var requestFailed = exception as RequestFailedException; + return new RoutingDiagnosticError( + source, + operation, + resourceId, + requestFailed?.Status, + requestFailed?.ErrorCode ?? exception.GetType().Name, + requiredRole, + message); + } + + private static string GetQueryStatus( + bool hasSuccessfulQueries, + IReadOnlyList errors) + { + if (errors.Count == 0) + { + return "queried"; + } + if (hasSuccessfulQueries) + { + return "partial"; + } + if (errors.Any(error => error.StatusCode is 401 or 403)) + { + return "unauthorized"; + } + return errors.Any(error => error.StatusCode == 404) ? "notFound" : "failed"; + } + + private static RoutingHubEmitted CreateEmptyHubEvidence() => new( + new RoutingMetricEvidence("Microsoft.Devices/IotHubs", [], []), + []); + + private static RoutingTargetEmitted CreateEmptyTargetEvidence(string status) => new( + status, + "parentResource", + null, + [], + [], + []); + + private static void ValidateInputs( + string hubName, + string resourceGroup, + string subscription) + { + ValidateRequiredParameters( + (nameof(subscription), subscription), + (nameof(resourceGroup), resourceGroup), + (nameof(hubName), hubName)); + IoTHubValidation.ThrowIfInvalidResourceNames(hubName, resourceGroup); + } + + private static ResourceIdentifier BuildResourceId( + string subscription, + string resourceGroup, + string resourceType, + string resourceName) => + new($"/subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/{resourceType}/{resourceName}"); + + private static string ToIsoString(DateTimeOffset value) => + value.ToUniversalTime().ToString( + "yyyy-MM-ddTHH:mm:ss.fffffffZ", + CultureInfo.InvariantCulture); + + private static readonly IReadOnlyDictionary EmptyDimensions = + new Dictionary(); +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Services/IoTHubService.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Services/IoTHubService.cs index d8635775e..e57795b22 100644 --- a/tools/Azure.Mcp.Tools.IoTHub/src/Services/IoTHubService.cs +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Services/IoTHubService.cs @@ -5,6 +5,7 @@ using Azure.Core; using Azure.Mcp.Core.Services.Azure; using Azure.Mcp.Tools.IoTHub.Commands; using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Routing; using Azure.ResourceManager.Resources; using Microsoft.Extensions.Logging; @@ -26,6 +27,7 @@ public class IoTHubService(IAzureService azureService, ILogger lo (nameof(subscription), subscription), (nameof(resourceGroup), resourceGroup), (nameof(hubName), hubName)); + IoTHubValidation.ThrowIfInvalidResourceNames(hubName, resourceGroup); try { @@ -62,7 +64,8 @@ public class IoTHubService(IAzureService azureService, ILogger lo hub.Sku?.Name ?? string.Empty, hub.Sku?.Capacity ?? 0, properties?.State ?? string.Empty, - properties?.HostName ?? string.Empty + properties?.HostName ?? string.Empty, + RoutingEndpointMapper.ConvertToRoutingEndpointDetailsList(properties?.Routing?.Endpoints) ); } } diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Services/ObservationWindow.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Services/ObservationWindow.cs new file mode 100644 index 000000000..e300208d4 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Services/ObservationWindow.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; + +namespace Azure.Mcp.Tools.IoTHub.Services; + +internal readonly record struct ObservationWindow( + DateTimeOffset StartTime, + DateTimeOffset EndTime) +{ + public string Timespan => $"{ToIsoString(StartTime)}/{ToIsoString(EndTime)}"; + + private static string ToIsoString(DateTimeOffset value) => + value.ToUniversalTime().ToString( + "yyyy-MM-ddTHH:mm:ss.fffffffZ", + CultureInfo.InvariantCulture); +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Services/ResolvedTarget.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Services/ResolvedTarget.cs new file mode 100644 index 000000000..f09aa4338 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Services/ResolvedTarget.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; + +namespace Azure.Mcp.Tools.IoTHub.Services; + +internal sealed record ResolvedTarget( + RoutingTargetDescriptor Descriptor, + ResourceIdentifier ParentResourceId, + ResourceIdentifier RoutedResourceId); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Services/RoutingTargetDescriptor.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Services/RoutingTargetDescriptor.cs new file mode 100644 index 000000000..7d4623351 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Services/RoutingTargetDescriptor.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Services; + +internal sealed record RoutingTargetDescriptor( + string EndpointType, + string ResourceType, + string ApiVersion); diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/IoTHubCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/IoTHubCommandTests.cs index a568212fa..b7afc0815 100644 --- a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/IoTHubCommandTests.cs +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/IoTHubCommandTests.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Microsoft.Mcp.Tests; using Microsoft.Mcp.Tests.Client; using Microsoft.Mcp.Tests.Client.Helpers; +using Microsoft.Mcp.Tests.Generated.Models; using Xunit; namespace Azure.Mcp.Tools.IoTHub.Tests; @@ -15,6 +16,47 @@ public class IoTHubCommandTests( LiveServerFixture liveServerFixture) : RecordedCommandTestsBase(output, fixture, liveServerFixture) { + // Endpoint names and metric dimension names must remain distinct during playback. + public override List DisabledDefaultSanitizers => [.. base.DisabledDefaultSanitizers, "AZSDK3493"]; + + public override List GeneralRegexSanitizers { get; } = + [ + new(new() + { + Regex = @"(?i)(/subscriptions/|""subscriptionId""\s*:\s*"")(?[0-9a-f-]{36})", + GroupForReplace = "subscription", + Value = "00000000-0000-0000-0000-000000000000" + }), + new(new() + { + Regex = @"(?i)(/resourceGroups/|resource group ')(?[^/'""?]+)", + GroupForReplace = "group", + Value = "Sanitized" + }) + ]; + + // Routing evidence does not use subscription/resource tags or subscription display names. + public override List BodyRegexSanitizers { get; } = + [ + new(new() + { + Regex = @"""tags""\s*:\s*\{(?:[^""{}]|""(?:\\.|[^""\\])*"")*\}", + Value = "\"tags\": {}" + }) + ]; + + public override List BodyKeySanitizers { get; } = + [ + new(new("$..displayName") { Value = "Sanitized" }), + new(new("$..resourcegroup") { Value = "Sanitized" }) + ]; + + public override List HeaderRegexSanitizers => + [ + .. base.HeaderRegexSanitizers, + new(new("x-ms-operation-identifier") { Value = "Sanitized" }) + ]; + [Fact] public async Task IoTHubDevice_ListDevices() { @@ -64,4 +106,68 @@ public class IoTHubCommandTests( areResultsTruncated.ValueKind is JsonValueKind.True or JsonValueKind.False); } + + [Fact] + public async Task Should_get_iot_hub_routing_endpoint_health() + { + var result = await CallToolAsync("iothub_routing_endpoint-health", new() + { + { "hub-name", Settings.ResourceBaseName }, + { "resource-group", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "tenant", Settings.TenantId } + }); + + Assert.NotNull(result); + var endpoints = result.Value.AssertProperty("endpoints"); + Assert.Equal(JsonValueKind.Array, endpoints.ValueKind); + Assert.Contains(endpoints.EnumerateArray(), endpoint => endpoint.GetProperty("endpointName").GetString() == "sbqueue-endpoint"); + Assert.Contains(endpoints.EnumerateArray(), endpoint => endpoint.GetProperty("endpointName").GetString() == "sbtopic-endpoint"); + foreach (var endpoint in endpoints.EnumerateArray()) + { + Assert.Equal(JsonValueKind.String, endpoint.GetProperty("endpointId").ValueKind); + Assert.Equal(JsonValueKind.String, endpoint.GetProperty("endpointName").ValueKind); + Assert.Equal(JsonValueKind.String, endpoint.GetProperty("healthStatus").ValueKind); + } + } + + [Fact] + public async Task Should_get_iot_hub_routing_endpoint_diagnostics() + { + var endTime = RegisterOrRetrieveVariable("RoutingEndTime", DateTimeOffset.UtcNow.ToString("O")); + var startTime = RegisterOrRetrieveVariable("RoutingStartTime", DateTimeOffset.Parse(endTime).AddHours(-6).ToString("O")); + var result = await CallToolAsync("iothub_routing_endpoint-diagnostics", new() + { + { "hub-name", Settings.ResourceBaseName }, + { "resource-group", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "tenant", Settings.TenantId }, + { "start-time", startTime }, + { "end-time", endTime }, + { "interval", "PT1H" } + }); + + Assert.NotNull(result); + var observationWindow = result.Value.AssertProperty("observationWindow"); + Assert.Equal(JsonValueKind.Object, observationWindow.ValueKind); + var endpoints = result.Value.AssertProperty("endpoints"); + Assert.Equal(JsonValueKind.Array, endpoints.ValueKind); + var queue = Assert.Single(endpoints.EnumerateArray(), endpoint => endpoint.GetProperty("name").GetString() == "sbqueue-endpoint"); + var topic = Assert.Single(endpoints.EnumerateArray(), endpoint => endpoint.GetProperty("name").GetString() == "sbtopic-endpoint"); + Assert.Equal("exists", queue.GetProperty("target").GetProperty("existenceStatus").GetString()); + Assert.Equal("exists", topic.GetProperty("target").GetProperty("existenceStatus").GetString()); + Assert.Equal( + queue.GetProperty("target").GetProperty("resourceId").GetString(), + topic.GetProperty("target").GetProperty("resourceId").GetString()); + Assert.Equal("queried", queue.GetProperty("targetEmitted").GetProperty("queryStatus").GetString()); + Assert.Equal(queue.GetProperty("targetEmitted").GetRawText(), topic.GetProperty("targetEmitted").GetRawText()); + foreach (var endpoint in endpoints.EnumerateArray()) + { + Assert.Equal(JsonValueKind.String, endpoint.GetProperty("name").ValueKind); + Assert.Equal(JsonValueKind.String, endpoint.GetProperty("endpointType").ValueKind); + Assert.Equal(JsonValueKind.Object, endpoint.GetProperty("hubEmitted").ValueKind); + Assert.Equal(JsonValueKind.Object, endpoint.GetProperty("targetEmitted").ValueKind); + Assert.False(endpoint.TryGetProperty("health", out _)); + } + } } diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/IoTHubValidationTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/IoTHubValidationTests.cs new file mode 100644 index 000000000..bb45e57bc --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/IoTHubValidationTests.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.IoTHub.Commands; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.Tests; + +public class IoTHubValidationTests() +{ + [Theory] + [InlineData("rg1")] + [InlineData("a")] + [InlineData("my-rg_(test).v2")] + [InlineData("衄源组")] + [InlineData("\U00020000rg")] + public void IsValidResourceGroupName_AcceptsArmResourceGroupNames(string resourceGroup) + { + Assert.True(IoTHubValidation.IsValidResourceGroupName(resourceGroup)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("rg1/../rg2")] + [InlineData(@"rg1\..\rg2")] + [InlineData("..")] + [InlineData(".")] + [InlineData("rg.")] + [InlineData("rg1%2F..%2Frg2")] + [InlineData("rg?x")] + [InlineData("rg#x")] + [InlineData("rg x")] + [InlineData("rg1\n")] + [InlineData("rg\u0301")] + [InlineData("rg\u203F")] + public void IsValidResourceGroupName_RejectsValuesOutsideArmNamingRules(string? resourceGroup) + { + Assert.False(IoTHubValidation.IsValidResourceGroupName(resourceGroup)); + } + + [Fact] + public void IsValidResourceGroupName_EnforcesArmLengthLimit() + { + Assert.True(IoTHubValidation.IsValidResourceGroupName(new string('a', 90))); + Assert.False(IoTHubValidation.IsValidResourceGroupName(new string('a', 91))); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/IoTHubRoutingEndpointDiagnosticsCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/IoTHubRoutingEndpointDiagnosticsCommandTests.cs new file mode 100644 index 000000000..466110e3c --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/IoTHubRoutingEndpointDiagnosticsCommandTests.cs @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tests.Commands; +using Azure.Mcp.Tools.IoTHub.Commands; +using Azure.Mcp.Tools.IoTHub.Commands.Routing; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Routing; +using Azure.Mcp.Tools.IoTHub.Services; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.Tests.Routing; + +public class IoTHubRoutingEndpointDiagnosticsCommandTests + : SubscriptionCommandUnitTestsBase +{ + [Fact] + public async Task ExecuteAsync_ReportsOperationTimeout() + { + Service.GetRoutingEndpointDiagnostics( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new TimeoutException("Routing diagnostics operation timed out.")); + + var response = await ExecuteCommandAsync( + "--subscription sub123 --resource-group rg1 --hub-name hub1"); + + Assert.Equal(HttpStatusCode.RequestTimeout, response.Status); + Assert.Contains("timed out", response.Message); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = Command.GetCommand(); + + Assert.Equal("endpoint-diagnostics", command.Name); + Assert.DoesNotContain(command.Options, option => option.Name.StartsWith("retry", StringComparison.Ordinal)); + Assert.DoesNotContain(command.Options, option => option.Name == "lookback"); + } + + [Theory] + [InlineData("--subscription sub123 --resource-group rg1 --hub-name hub1", true)] + [InlineData("--subscription sub123 --resource-group rg1 --hub-name hub1 --endpoint-name endpoint1", true)] + [InlineData("--subscription sub123 --resource-group rg1 --hub-name hub1 --start-time 2026-08-01T00:00:00Z --end-time 2026-08-01T12:00:00Z --interval PT1M", true)] + [InlineData("--subscription sub123 --hub-name hub1", false)] + [InlineData("--subscription sub123 --resource-group rg1", false)] + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + if (shouldSucceed) + { + Service.GetRoutingEndpointDiagnostics( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(CreateResult()); + } + + var response = await ExecuteCommandAsync(args); + + Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); + } + + [Theory] + [InlineData("--start-time 2026-08-01T00:00:00Z", "--start-time and --end-time must be provided together")] + [InlineData("--start-time 2026-08-02T00:00:00Z --end-time 2026-08-01T00:00:00Z", "--start-time must be earlier than --end-time")] + [InlineData("--start-time 2026-07-01T00:00:00Z --end-time 2026-08-01T00:00:01Z", "cannot exceed 30 days")] + [InlineData("--interval PT2M", "--interval must be one of")] + [InlineData("--interval PT1M", "maximum is 720")] + public async Task ExecuteAsync_RejectsInvalidWindow(string args, string expectedMessage) + { + var response = await ExecuteCommandAsync( + $"--subscription sub123 --resource-group rg1 --hub-name hub1 {args}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains(expectedMessage, response.Message); + } + + [Fact] + public async Task ExecuteAsync_PassesAbsoluteWindowAndInterval() + { + var startTime = new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.Zero); + var endTime = startTime.AddDays(30); + Service.GetRoutingEndpointDiagnostics( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(CreateResult()); + + var response = await ExecuteCommandAsync( + "--subscription", "sub123", + "--resource-group", "rg1", + "--hub-name", "hub1", + "--endpoint-name", "endpoint1", + "--start-time", startTime.ToString("O"), + "--end-time", endTime.ToString("O"), + "--interval", "PT1H"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await Service.Received(1).GetRoutingEndpointDiagnostics( + "hub1", + "rg1", + "sub123", + "endpoint1", + startTime, + endTime, + "PT1H", + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_DeserializesEvidenceOnlyResult() + { + Service.GetRoutingEndpointDiagnostics( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(CreateResult()); + + var response = await ExecuteCommandAsync( + "--subscription", "sub123", + "--resource-group", "rg1", + "--hub-name", "hub1"); + + var result = ValidateAndDeserializeResponse( + response, + IoTHubJsonContext.Default.RoutingEndpointDiagnostics); + Assert.Equal("PT1H", result.ObservationWindow.Interval); + Assert.Empty(result.Endpoints); + } + + [Fact] + public async Task ExecuteAsync_SerializesDirectMetricFieldsAndIntegerCounts() + { + var timestamp = new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.Zero); + var builder = new RoutingMetricEvidenceBuilder("Microsoft.Devices/IotHubs"); + builder.AddCountSeries( + "RoutingDeliveries", + "Total", + new Dictionary { ["Result"] = "Success" }, + [(timestamp, 5d)]); + builder.AddAverageSeries( + "RoutingDeliveryLatency", + "Milliseconds", + new Dictionary(), + [(timestamp, 12.5d)]); + var metrics = builder.Build(); + var result = new RoutingEndpointDiagnostics( + new RoutingObservationWindow( + "2026-08-01T00:00:00.0000000Z", + "2026-08-02T00:00:00.0000000Z", + "PT1H"), + [ + new RoutingEndpointDiagnostic( + "endpoint-id", + "endpoint1", + "EventHub", + "identityBased", + "sb://namespace.servicebus.windows.net", + "sub123", + "rg1", + "namespace", + "entity", + null, + null, + null, + new RoutingTargetInfo( + "resolved", + "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.EventHub/namespaces/namespace", + ExistenceStatus: "indeterminate", + Error: new RoutingDiagnosticError( + "targetArm", "read", null, 403, "AuthorizationFailed", "Reader", "Existence unavailable.")), + new RoutingHubEmitted(metrics, []), + new RoutingTargetEmitted( + "queried", + "parentResource", + "Microsoft.EventHub/namespaces", + [], + [], + []) + { + MetricAvailability = new Dictionary + { + ["SuccessfulRequests"] = "noValuesReturned" + } + }) + ]); + Service.GetRoutingEndpointDiagnostics( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(result); + + var response = await ExecuteCommandAsync( + "--subscription", "sub123", + "--resource-group", "rg1", + "--hub-name", "hub1"); + + var deserialized = ValidateAndDeserializeResponse( + response, + IoTHubJsonContext.Default.RoutingEndpointDiagnostics); + var endpoint = Assert.Single(deserialized.Endpoints); + var bucket = Assert.Single(endpoint.HubEmitted.RoutingMetrics.Buckets); + Assert.Equal(5, bucket["routingDeliveries.result.success.total.count"].GetInt64()); + Assert.Equal(12.5d, bucket["routingDeliveryLatency.avg.ms"].GetDouble()); + Assert.Equal("valuesReturned", endpoint.HubEmitted.RoutingMetrics.MetricAvailability["RoutingDeliveries"]); + Assert.Equal("noValuesReturned", endpoint.TargetEmitted.MetricAvailability["SuccessfulRequests"]); + Assert.Equal("AuthorizationFailed", endpoint.Target.Error?.Code); + Assert.Empty(endpoint.TargetEmitted.Errors); + } + + [Fact] + public async Task ExecuteAsync_HandlesServiceErrors() + { + Service.GetRoutingEndpointDiagnostics( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new Exception("Test error")); + + var response = await ExecuteCommandAsync( + "--subscription", "sub123", + "--resource-group", "rg1", + "--hub-name", "hub1"); + + Assert.Equal(HttpStatusCode.InternalServerError, response.Status); + Assert.Contains("Test error", response.Message); + } + + private static RoutingEndpointDiagnostics CreateResult() => new( + new RoutingObservationWindow( + "2026-08-01T00:00:00.0000000Z", + "2026-08-02T00:00:00.0000000Z", + "PT1H"), + []); +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/IoTHubRoutingEndpointHealthCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/IoTHubRoutingEndpointHealthCommandTests.cs new file mode 100644 index 000000000..636d1f78f --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/IoTHubRoutingEndpointHealthCommandTests.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tests.Commands; +using Azure.Mcp.Tools.IoTHub.Commands; +using Azure.Mcp.Tools.IoTHub.Commands.Routing; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Services; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.Tests.Routing; + +public class IoTHubRoutingEndpointHealthCommandTests + : SubscriptionCommandUnitTestsBase +{ + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = Command.GetCommand(); + + Assert.Equal("endpoint-health", command.Name); + Assert.DoesNotContain(command.Options, option => option.Name.StartsWith("retry", StringComparison.Ordinal)); + Assert.DoesNotContain(command.Options, option => option.Name is "lookback" or "start-time" or "end-time" or "interval"); + } + + [Theory] + [InlineData("--subscription sub123 --resource-group rg1 --hub-name hub1", true)] + [InlineData("--subscription sub123 --resource-group rg1 --hub-name hub1 --endpoint-name endpoint1", true)] + [InlineData("--subscription sub123 --hub-name hub1", false)] + [InlineData("--subscription sub123 --resource-group rg1", false)] + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + if (shouldSucceed) + { + Service.GetRoutingEndpointHealth( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns([]); + } + + var response = await ExecuteCommandAsync(args); + + Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); + } + + [Fact] + public async Task ExecuteAsync_ReturnsRawSnapshotFields() + { + Service.GetRoutingEndpointHealth( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns( + [ + new RoutingEndpointHealthSnapshot( + "endpoint-id", + "endpoint1", + "unhealthy", + "NotFound", + "Wed, 27 Aug 2026 19:42:00 GMT", + "Wed, 27 Aug 2026 19:40:00 GMT", + "Wed, 27 Aug 2026 19:59:00 GMT") + ]); + + var response = await ExecuteCommandAsync( + "--subscription", "sub123", + "--resource-group", "rg1", + "--hub-name", "hub1"); + + var result = ValidateAndDeserializeResponse( + response, + IoTHubJsonContext.Default.RoutingEndpointHealthResult); + var endpoint = Assert.Single(result.Endpoints); + Assert.Equal("endpoint-id", endpoint.EndpointId); + Assert.Equal("endpoint1", endpoint.EndpointName); + Assert.Equal("unhealthy", endpoint.HealthStatus); + Assert.Equal("NotFound", endpoint.LastKnownError); + Assert.Equal("Wed, 27 Aug 2026 19:42:00 GMT", endpoint.LastKnownErrorTime); + Assert.Equal("Wed, 27 Aug 2026 19:40:00 GMT", endpoint.LastSuccessfulSendAttemptTime); + Assert.Equal("Wed, 27 Aug 2026 19:59:00 GMT", endpoint.LastSendAttemptTime); + } + + [Fact] + public async Task ExecuteAsync_PassesEndpointNameToService() + { + Service.GetRoutingEndpointHealth( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns([]); + + await ExecuteCommandAsync( + "--subscription", "sub123", + "--resource-group", "rg1", + "--hub-name", "hub1", + "--endpoint-name", "endpoint1"); + + await Service.Received(1).GetRoutingEndpointHealth( + "hub1", + "rg1", + "sub123", + "endpoint1", + Arg.Any(), + Arg.Any()); + } + + [Theory] + [InlineData("ab")] + [InlineData("hub-name-")] + [InlineData("hub!name")] + [InlineData("hub' OR 1=1")] + public async Task ExecuteAsync_RejectsInvalidIoTHubName(string invalidName) + { + var response = await ExecuteCommandAsync( + "--subscription", "sub123", + "--resource-group", "rg1", + "--hub-name", invalidName); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("--hub-name must be 3-50 characters long", response.Message); + } + + [Fact] + public async Task ExecuteAsync_ReportsOperationTimeout() + { + Service.GetRoutingEndpointHealth( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new TimeoutException("Routing health operation timed out.")); + + var response = await ExecuteCommandAsync( + "--subscription sub123 --resource-group rg1 --hub-name hub1"); + + Assert.Equal(HttpStatusCode.RequestTimeout, response.Status); + Assert.Contains("timed out", response.Message); + } + + [Fact] + public async Task ExecuteAsync_HandlesServiceErrors() + { + Service.GetRoutingEndpointHealth( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new Exception("Test error")); + + var response = await ExecuteCommandAsync( + "--subscription", "sub123", + "--resource-group", "rg1", + "--hub-name", "hub1"); + + Assert.Equal(HttpStatusCode.InternalServerError, response.Status); + Assert.Contains("Test error", response.Message); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/RoutingDiagnosticsWindowTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/RoutingDiagnosticsWindowTests.cs new file mode 100644 index 000000000..fbdd3bc65 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/RoutingDiagnosticsWindowTests.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Xml; +using Azure.Mcp.Tools.IoTHub.Routing; +using Microsoft.Mcp.Core.Commands; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.Tests.Routing; + +public class RoutingDiagnosticsWindowTests +{ + [Fact] + public void Resolve_DefaultsToPreviousTwentyFourHours() + { + var currentTime = new DateTimeOffset(2026, 8, 1, 12, 0, 0, TimeSpan.Zero); + + var result = RoutingDiagnosticsWindow.Resolve( + null, + null, + null, + currentTime); + + Assert.Equal(currentTime.AddHours(-24), result.StartTime); + Assert.Equal(currentTime, result.EndTime); + Assert.Equal(TimeSpan.FromHours(1), result.Interval); + } + + [Theory] + [InlineData(12, "PT1M")] + [InlineData(24, "PT5M")] + [InlineData(720, "PT1H")] + public void Resolve_AcceptsAtMostSevenHundredTwentyBuckets( + int durationHours, + string interval) + { + var startTime = new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.Zero); + + var result = RoutingDiagnosticsWindow.Resolve( + startTime, + startTime.AddHours(durationHours), + interval); + + Assert.Equal(XmlConvert.ToTimeSpan(interval), result.Interval); + } + + [Fact] + public void Resolve_RejectsMoreThanSevenHundredTwentyBuckets() + { + var currentTime = new DateTimeOffset(2026, 8, 1, 12, 0, 0, TimeSpan.Zero); + + var exception = Assert.Throws(() => + RoutingDiagnosticsWindow.Resolve(null, null, "PT1M", currentTime)); + + Assert.Contains("produce 1440 buckets", exception.Message); + Assert.Contains("maximum is 720", exception.Message); + Assert.Contains("PT5M or larger", exception.Message); + } + + [Fact] + public void Validate_KeepsOptionValuesOutOfTelemetry() + { + var startTime = new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.Zero); + var validationResult = new ValidationResult(); + + RoutingDiagnosticsWindow.Validate(startTime, startTime.AddDays(2), "PT1M", validationResult); + + var error = Assert.Single(validationResult.Errors); + Assert.Contains("produce 2880 buckets", error); + Assert.Equal(RoutingDiagnosticsWindow.InvalidWindowTelemetryMessage, validationResult.TelemetrySafeMessage); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/RoutingEndpointMapperTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/RoutingEndpointMapperTests.cs new file mode 100644 index 000000000..eb1debe4f --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/RoutingEndpointMapperTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Routing; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.Tests.Routing; + +public class RoutingEndpointMapperTests() +{ + [Fact] + public void ConvertToRoutingEndpointDetailsList_HandlesAbsentEndpoints() + { + Assert.Empty(RoutingEndpointMapper.ConvertToRoutingEndpointDetailsList(null)); + } + + [Fact] + public void ConvertToRoutingEndpointDetailsList_PreservesAllEndpointKinds() + { + var endpoint = new RoutingEndpointProperties + { + Id = "dbc3cb4d-0119-4d46-9824-e8e3e83fb04a", + Name = "endpoint1", + SubscriptionId = "11111111-1111-1111-1111-111111111111", + ResourceGroup = "rg1", + EndpointUri = "https://account1.blob.core.windows.net", + EntityPath = "entity1", + ContainerName = "container1", + DatabaseName = "database1", + AuthenticationType = "identityBased", + BatchFrequencyInSeconds = 60 + }; + var endpoints = new RoutingEndpoints + { + EventHubs = [endpoint], + ServiceBusQueues = [endpoint], + ServiceBusTopics = [endpoint], + StorageContainers = [endpoint], + CosmosDBSqlContainers = [endpoint] + }; + + var result = RoutingEndpointMapper.ConvertToRoutingEndpointDetailsList(endpoints); + + Assert.Equal( + ["EventHub", "ServiceBusQueue", "ServiceBusTopic", "StorageContainer", "CosmosDBSqlContainer"], + result.Select(value => value.EndpointType)); + Assert.All(result, value => + { + Assert.Equal(endpoint.Id, value.EndpointId); + Assert.Equal(endpoint.Name, value.Name); + Assert.Equal(endpoint.SubscriptionId, value.SubscriptionId); + Assert.Equal(endpoint.ResourceGroup, value.ResourceGroup); + Assert.Equal(endpoint.EndpointUri, value.EndpointUri); + Assert.Equal(endpoint.AuthenticationType, value.AuthenticationType); + Assert.Equal(60, value.BatchFrequencyInSeconds); + Assert.Equal("account1", value.EndpointResourceName); + }); + Assert.All(result.Take(3), value => + { + Assert.Equal("entity1", value.EntityPath); + Assert.Null(value.ContainerName); + Assert.Null(value.DatabaseName); + }); + Assert.All(result.Skip(3), value => + { + Assert.Equal("container1", value.ContainerName); + Assert.Null(value.EntityPath); + }); + Assert.Null(result[3].DatabaseName); + Assert.Equal("database1", result[4].DatabaseName); + } + + [Fact] + public void ConvertToRoutingEndpointDetailsList_LeavesKeyBasedTargetNameUnresolved() + { + // IoT Hub returns an opaque endpoint id and no endpointUri for key-based endpoints. + static RoutingEndpointProperties KeyBased(string? entityPath = null, string? containerName = null) => new() + { + Id = "dbc3cb4d-0119-4d46-9824-e8e3e83fb04a", + Name = "endpoint1", + SubscriptionId = "11111111-1111-1111-1111-111111111111", + ResourceGroup = "rg1", + EntityPath = entityPath, + ContainerName = containerName, + AuthenticationType = "keyBased" + }; + var endpoints = new RoutingEndpoints + { + EventHubs = [KeyBased(entityPath: "telemetry-hub")], + ServiceBusQueues = [KeyBased(entityPath: "telemetry-queue")], + ServiceBusTopics = [KeyBased(entityPath: "telemetry-topic")], + StorageContainers = [KeyBased(containerName: "telemetry")] + }; + + var result = RoutingEndpointMapper.ConvertToRoutingEndpointDetailsList(endpoints); + + Assert.Equal(4, result.Count); + Assert.All(result, value => + { + Assert.Null(value.EndpointResourceName); + Assert.Equal("dbc3cb4d-0119-4d46-9824-e8e3e83fb04a", value.EndpointId); + }); + Assert.Equal(["telemetry-hub", "telemetry-queue", "telemetry-topic"], result.Take(3).Select(value => value.EntityPath)); + Assert.Equal("telemetry", result[3].ContainerName); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/RoutingMetricEvidenceBuilderTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/RoutingMetricEvidenceBuilderTests.cs new file mode 100644 index 000000000..8fd710f1d --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Routing/RoutingMetricEvidenceBuilderTests.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.IoTHub.Routing; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.Tests.Routing; + +public class RoutingMetricEvidenceBuilderTests +{ + [Fact] + public void Availability_DistinguishesEmptySuccessfulAndFailedMetrics() + { + var builder = new RoutingMetricEvidenceBuilder("namespace"); + builder.AddCountSeries("EmptyCount", "Total", new Dictionary(), []); + builder.AddAverageSeries("EmptyAverage", "Milliseconds", new Dictionary(), []); + builder.AddMaximumSeries("EmptyMaximum", "Percent", new Dictionary(), []); + builder.MarkFailed("Failed"); + builder.MarkSuccessful("Failed"); + builder.AddCountSeries("Zero", "Total", new Dictionary(), [(DateTimeOffset.UtcNow, 0d)]); + + var result = builder.Build(); + + Assert.Equal("noValuesReturned", result.MetricAvailability["EmptyCount"]); + Assert.Equal("noValuesReturned", result.MetricAvailability["EmptyAverage"]); + Assert.Equal("noValuesReturned", result.MetricAvailability["EmptyMaximum"]); + Assert.Equal("failed", result.MetricAvailability["Failed"]); + Assert.Equal("valuesReturned", result.MetricAvailability["Zero"]); + } + + [Fact] + public void AddCountSeries_InvalidLaterPointDoesNotLeaveUnlabelledPartialValues() + { + var builder = new RoutingMetricEvidenceBuilder("namespace"); + var timestamp = DateTimeOffset.UtcNow; + + Assert.Throws(() => builder.AddCountSeries( + "Count", "Total", new Dictionary(), [(timestamp, 1d), (timestamp.AddHours(1), 0.5d)])); + + Assert.Empty(builder.Build().Buckets); + Assert.Empty(builder.Build().WindowAggregates); + } + + [Fact] + public void AddCountSeries_SerializesIntegerBucketsAndAggregate() + { + var start = new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.Zero); + var builder = new RoutingMetricEvidenceBuilder("Microsoft.Devices/IotHubs"); + + builder.AddCountSeries( + "RoutingDeliveries", + "Total", + new Dictionary + { + ["Result"] = "Failure", + ["FailureReasonCategory"] = "Dropped" + }, + [(start, 2d), (start.AddHours(1), 3d)]); + + var result = builder.Build(); + const string field = + "routingDeliveries.result.failure.failureReasonCategory.dropped.total.count"; + Assert.Equal(5, result.WindowAggregates[field].GetInt64()); + Assert.Equal(2, result.Buckets[0][field].GetInt64()); + Assert.Equal(3, result.Buckets[1][field].GetInt64()); + } + + [Fact] + public void AddCountSeries_RejectsFractionalCounts() + { + var builder = new RoutingMetricEvidenceBuilder("Microsoft.Storage/storageAccounts"); + + var exception = Assert.Throws(() => + builder.AddCountSeries( + "Transactions", + "Total", + new Dictionary { ["ResponseType"] = "Success" }, + [(DateTimeOffset.UtcNow, 1.5d)])); + + Assert.Contains("64-bit integer", exception.Message); + } + + [Fact] + public void AddAverageSeries_DoesNotCreateWindowAggregate() + { + var builder = new RoutingMetricEvidenceBuilder("Microsoft.Devices/IotHubs"); + + builder.AddAverageSeries( + "RoutingDeliveryLatency", + "Milliseconds", + new Dictionary(), + [(new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.Zero), 12.5d)]); + + var result = builder.Build(); + Assert.Empty(result.WindowAggregates); + Assert.Equal( + 12.5d, + result.Buckets[0]["routingDeliveryLatency.avg.ms"].GetDouble()); + } + + [Fact] + public void AddMaximumSeries_CreatesWindowMaximum() + { + var builder = new RoutingMetricEvidenceBuilder( + "Microsoft.DocumentDB/databaseAccounts"); + + builder.AddMaximumSeries( + "NormalizedRUConsumption", + "Percent", + new Dictionary(), + [ + (new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.Zero), 80d), + (new DateTimeOffset(2026, 8, 1, 1, 0, 0, TimeSpan.Zero), 95d) + ]); + + var result = builder.Build(); + Assert.Equal( + 95d, + result.WindowAggregates["normalizedRUConsumption.max.pct"].GetDouble()); + } + + [Fact] + public void GetBaseFieldName_PercentEncodesUnsafeSegments() + { + var result = MetricFieldNameBuilder.GetBaseFieldName( + "Metric.Name", + "Total", + "Count", + new Dictionary { ["Response.Type"] = "A/B" }); + + Assert.Equal("metric%2EName.response%2EType.a%2FB.total.count", result); + } + + [Fact] + public void AppendCollisionHash_IsStable() + { + const string identity = "namespace|metric|Total|Count|Dimension=Value"; + + var first = MetricFieldNameBuilder.AppendCollisionHash("metric.total.count", identity); + var second = MetricFieldNameBuilder.AppendCollisionHash("metric.total.count", identity); + + Assert.Equal(first, second); + Assert.Matches("""^metric\.total\.count~[0-9a-f]{8}$""", first); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/IoTHubRoutingServiceDiagnosticsTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/IoTHubRoutingServiceDiagnosticsTests.cs new file mode 100644 index 000000000..f8933bde5 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/IoTHubRoutingServiceDiagnosticsTests.cs @@ -0,0 +1,739 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Core; +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Tests.Helpers; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Services; +using Azure.ResourceManager; +using Azure.ResourceManager.Models; +using Azure.ResourceManager.Resources; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Services.Azure.Authentication; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.Tests.Services; + +public class IoTHubRoutingServiceDiagnosticsTests() +{ + private const string Subscription = "11111111-1111-1111-1111-111111111111"; + private const string HubId = $"/subscriptions/{Subscription}/resourceGroups/rg1/providers/Microsoft.Devices/IotHubs/hub1"; + private static readonly DateTimeOffset s_startTime = new(2026, 8, 1, 0, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task Diagnostics_ReturnsMoreThanTenSeriesAndFiltersSelectedEndpoint() + { + using var handler = CreateHandler((request, _) => + { + var name = GetQuery(request, "metricnames"); + var top = int.TryParse(GetQuery(request, "top"), out var value) ? value : 10; + return name switch + { + "RoutingDeliveries" => Metrics(Metric(name, Series(Math.Min(top, 15), true))), + "Transactions" => Metrics(Metric(name, Series(Math.Min(top, 15), false))), + _ => Metrics(Metric(name)) + }; + }); + + var result = await DiagnoseAsync(CreateService(handler), TestContext.Current.CancellationToken, endpointName: "endpoint1"); + + var endpoint = Assert.Single(result.Endpoints); + Assert.Equal(15, endpoint.HubEmitted.RoutingMetrics.WindowAggregates.Count); + Assert.Equal(15, endpoint.TargetEmitted.WindowAggregates.Count); + Assert.Equal("valuesReturned", endpoint.HubEmitted.RoutingMetrics.MetricAvailability["RoutingDeliveries"]); + Assert.Equal("valuesReturned", endpoint.TargetEmitted.MetricAvailability["Transactions"]); + Assert.Empty(endpoint.HubEmitted.Errors); + Assert.Empty(endpoint.TargetEmitted.Errors); + var hubQueries = handler.RequestUris.Where(uri => uri.AbsolutePath.Contains("/IotHubs/", StringComparison.Ordinal) + && uri.AbsolutePath.EndsWith("/metrics", StringComparison.Ordinal)).ToArray(); + Assert.Equal(2, hubQueries.Length); + Assert.All(hubQueries, uri => + { + var filter = GetQuery(uri, "$filter"); + Assert.Contains("EndpointName eq 'endpoint1'", filter); + Assert.DoesNotContain("EndpointName eq '*'", filter); + Assert.Equal("10000", GetQuery(uri, "top")); + }); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Diagnostics_SignalsPossibleTruncationAtSeriesLimit(bool hubMetric) + { + using var handler = CreateHandler((request, _) => + { + var name = GetQuery(request, "metricnames"); + var selected = hubMetric ? "RoutingDeliveries" : "Transactions"; + return Metrics(Metric(name, name == selected + ? Series(IoTHubRoutingService.MetricSeriesLimit, hubMetric, distinctDimensions: false) + : "")); + }); + + var endpoint = Assert.Single((await DiagnoseAsync(CreateService(handler), TestContext.Current.CancellationToken)).Endpoints); + + var availability = hubMetric + ? endpoint.HubEmitted.RoutingMetrics.MetricAvailability + : endpoint.TargetEmitted.MetricAvailability; + var errors = hubMetric ? endpoint.HubEmitted.Errors : endpoint.TargetEmitted.Errors; + Assert.Equal("partial", availability[hubMetric ? "RoutingDeliveries" : "Transactions"]); + Assert.Equal("PossibleTruncation", Assert.Single(errors).Code); + if (!hubMetric) + { + Assert.Equal("partial", endpoint.TargetEmitted.QueryStatus); + } + } + + [Fact] + public async Task Diagnostics_ReportsEmbeddedHubErrorWithoutHidingOtherMetric() + { + using var handler = CreateHandler((request, _) => + { + var name = GetQuery(request, "metricnames"); + return Metrics(name == "RoutingDeliveries" + ? Metric(name, errorCode: "InvalidSamplingType", errorMessage: "Unsupported aggregation") + : name == "RoutingDeliveryLatency" + ? Metric(name, Series(1, true, aggregation: "average")) + : Metric(name)); + }); + + var endpoint = Assert.Single((await DiagnoseAsync(CreateService(handler), TestContext.Current.CancellationToken)).Endpoints); + + Assert.Equal("failed", endpoint.HubEmitted.RoutingMetrics.MetricAvailability["RoutingDeliveries"]); + Assert.Equal("valuesReturned", endpoint.HubEmitted.RoutingMetrics.MetricAvailability["RoutingDeliveryLatency"]); + Assert.Single(endpoint.HubEmitted.RoutingMetrics.Buckets); + var error = Assert.Single(endpoint.HubEmitted.Errors); + Assert.Equal("InvalidSamplingType", error.Code); + Assert.Equal(200, error.StatusCode); + Assert.Equal("RoutingDeliveries", error.Operation); + } + + [Theory] + [InlineData("BadArgument", "Unsupported metric", "BadArgument")] + [InlineData("", "Metric is unavailable", "MetricQueryFailed")] + [InlineData("BadArgument", "SharedAccessKey=fake-secret", "BadArgument")] + public async Task Diagnostics_PreservesMixedTargetBatchIncludingEmptySuccess( + string errorCode, string errorMessage, string expectedCode) + { + using var handler = CreateHandler((request, _) => + { + var name = GetQuery(request, "metricnames"); + return IsHubMetric(request) + ? Metrics(Metric(name)) + : Metrics( + Metric("SuccessfulRequests", Series(1, false)), + Metric("ServerErrors", errorCode: errorCode, errorMessage: errorMessage), + Metric("UserErrors"), + Metric("ThrottledRequests"), + Metric("QuotaExceededErrors")); + }, eventHub: true); + + var endpoint = Assert.Single((await DiagnoseAsync(CreateService(handler), TestContext.Current.CancellationToken)).Endpoints); + + Assert.Equal("partial", endpoint.TargetEmitted.QueryStatus); + Assert.Single(endpoint.TargetEmitted.WindowAggregates); + Assert.Equal("valuesReturned", endpoint.TargetEmitted.MetricAvailability["SuccessfulRequests"]); + Assert.Equal("failed", endpoint.TargetEmitted.MetricAvailability["ServerErrors"]); + Assert.Equal("noValuesReturned", endpoint.TargetEmitted.MetricAvailability["UserErrors"]); + var error = Assert.Single(endpoint.TargetEmitted.Errors); + Assert.Equal(expectedCode, error.Code); + Assert.DoesNotContain(errorMessage, error.Message); + Assert.Single(TargetMetricRequests(handler)); + } + + [Fact] + public async Task Diagnostics_EmptySuccessWithFailedMetricIsPartialNotFailed() + { + using var handler = CreateHandler((request, _) => IsHubMetric(request) + ? Metrics(Metric(GetQuery(request, "metricnames"))) + : Metrics( + Metric("SuccessfulRequests"), + Metric("ServerErrors", errorCode: "BadArgument"), + Metric("UserErrors", errorCode: "BadArgument"), + Metric("ThrottledRequests", errorCode: "BadArgument"), + Metric("QuotaExceededErrors", errorCode: "BadArgument")), eventHub: true); + + var endpoint = Assert.Single((await DiagnoseAsync(CreateService(handler), TestContext.Current.CancellationToken)).Endpoints); + + Assert.Equal("partial", endpoint.TargetEmitted.QueryStatus); + Assert.Empty(endpoint.TargetEmitted.WindowAggregates); + Assert.Empty(endpoint.TargetEmitted.Buckets); + Assert.Equal("noValuesReturned", endpoint.TargetEmitted.MetricAvailability["SuccessfulRequests"]); + Assert.Equal(4, endpoint.TargetEmitted.Errors.Count); + } + + [Fact] + public async Task Diagnostics_AllEmbeddedTargetErrorsAreFailedNotEmptySuccess() + { + using var handler = CreateHandler((request, _) => Metrics(Metric( + GetQuery(request, "metricnames"), errorCode: IsHubMetric(request) ? "Success" : "BadArgument"))); + + var endpoint = Assert.Single((await DiagnoseAsync(CreateService(handler), TestContext.Current.CancellationToken)).Endpoints); + + Assert.Equal("failed", endpoint.TargetEmitted.QueryStatus); + Assert.Equal("failed", endpoint.TargetEmitted.MetricAvailability["Transactions"]); + Assert.Single(endpoint.TargetEmitted.Errors); + } + + [Fact] + public async Task Diagnostics_InvalidMetricValuesDoNotDiscardValidBatchMetrics() + { + using var handler = CreateHandler((request, _) => IsHubMetric(request) + ? Metrics() + : Metrics( + Metric("SuccessfulRequests", Series(1, false, value: "3")), + Metric("ServerErrors", Series(1, false, value: "0.5")), + Metric("UserErrors"), + Metric("ThrottledRequests"), + Metric("QuotaExceededErrors")), eventHub: true); + + var endpoint = Assert.Single((await DiagnoseAsync(CreateService(handler), TestContext.Current.CancellationToken)).Endpoints); + + Assert.Equal("partial", endpoint.TargetEmitted.QueryStatus); + Assert.Equal(3, Assert.Single(endpoint.TargetEmitted.WindowAggregates).Value.GetInt64()); + Assert.Equal("failed", endpoint.TargetEmitted.MetricAvailability["ServerErrors"]); + Assert.Single(endpoint.TargetEmitted.Errors); + Assert.Single(TargetMetricRequests(handler)); + } + + [Fact] + public async Task Diagnostics_EmptyMetricListReportsMissingMetrics() + { + using var handler = CreateHandler((_, _) => Metrics()); + + var endpoint = Assert.Single((await DiagnoseAsync(CreateService(handler), TestContext.Current.CancellationToken)).Endpoints); + + Assert.Equal("failed", endpoint.TargetEmitted.QueryStatus); + Assert.Equal("failed", endpoint.TargetEmitted.MetricAvailability["Transactions"]); + Assert.Equal("MissingMetricResponse", Assert.Single(endpoint.TargetEmitted.Errors).Code); + Assert.Equal(2, endpoint.HubEmitted.RoutingMetrics.MetricAvailability.Count); + Assert.All(endpoint.HubEmitted.RoutingMetrics.MetricAvailability.Values, + status => Assert.Equal("failed", status)); + Assert.Equal(2, endpoint.HubEmitted.Errors.Count); + Assert.All(endpoint.HubEmitted.Errors, error => Assert.Equal("MissingMetricResponse", error.Code)); + } + + [Fact] + public async Task Diagnostics_MissingBatchMetricPreservesReturnedValuesAndEmptySuccess() + { + using var handler = CreateHandler((request, _) => IsHubMetric(request) + ? Metrics(Metric(GetQuery(request, "metricnames"))) + : Metrics( + Metric("SuccessfulRequests", Series(1, false)), + Metric("ServerErrors"), + Metric("UserErrors"), + Metric("QuotaExceededErrors")), eventHub: true); + + var endpoint = Assert.Single((await DiagnoseAsync(CreateService(handler), TestContext.Current.CancellationToken)).Endpoints); + + Assert.Equal("partial", endpoint.TargetEmitted.QueryStatus); + Assert.Single(endpoint.TargetEmitted.WindowAggregates); + Assert.Equal("valuesReturned", endpoint.TargetEmitted.MetricAvailability["SuccessfulRequests"]); + Assert.Equal("noValuesReturned", endpoint.TargetEmitted.MetricAvailability["ServerErrors"]); + Assert.Equal("failed", endpoint.TargetEmitted.MetricAvailability["ThrottledRequests"]); + var error = Assert.Single(endpoint.TargetEmitted.Errors); + Assert.Equal("MissingMetricResponse", error.Code); + Assert.Equal("ThrottledRequests", error.Operation); + Assert.Empty(endpoint.HubEmitted.Errors); + Assert.Single(TargetMetricRequests(handler)); + } + + [Theory] + [InlineData("")] + [InlineData("UnexpectedMetric")] + public async Task Diagnostics_UnmatchedMetricObjectsDoNotImplyRequestedMetricSuccess(string responseMetricName) + { + using var handler = CreateHandler((_, _) => Metrics(Metric(responseMetricName))); + + var endpoint = Assert.Single((await DiagnoseAsync(CreateService(handler), TestContext.Current.CancellationToken)).Endpoints); + + Assert.Equal("failed", endpoint.TargetEmitted.QueryStatus); + Assert.Equal("failed", endpoint.TargetEmitted.MetricAvailability["Transactions"]); + Assert.Equal("MissingMetricResponse", Assert.Single(endpoint.TargetEmitted.Errors).Code); + Assert.Equal(2, endpoint.HubEmitted.Errors.Count); + Assert.All(endpoint.HubEmitted.Errors, error => Assert.Equal("MissingMetricResponse", error.Code)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Diagnostics_EmptySuccessfulResponsesDoNotInventZeroes(bool nullValues) + { + using var handler = CreateHandler((request, _) => Metrics(Metric( + GetQuery(request, "metricnames"), + nullValues ? Series(1, IsHubMetric(request), value: "null") : ""))); + + var endpoint = Assert.Single((await DiagnoseAsync(CreateService(handler), TestContext.Current.CancellationToken)).Endpoints); + + Assert.Equal("queried", endpoint.TargetEmitted.QueryStatus); + Assert.All(endpoint.HubEmitted.RoutingMetrics.MetricAvailability.Values, + status => Assert.Equal("noValuesReturned", status)); + Assert.Equal("noValuesReturned", endpoint.TargetEmitted.MetricAvailability["Transactions"]); + Assert.Empty(endpoint.HubEmitted.RoutingMetrics.Buckets); + Assert.Empty(endpoint.HubEmitted.RoutingMetrics.WindowAggregates); + Assert.Empty(endpoint.TargetEmitted.Buckets); + Assert.Empty(endpoint.TargetEmitted.WindowAggregates); + Assert.Empty(endpoint.TargetEmitted.Errors); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task RoutingOperations_PropagateCancellationDuringHubLookup( + bool health, bool credentialStage) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var handler = CreateHandler((_, _) => Metrics()); + var azureService = AzureServiceTestHelpers.CreateAzureService(); + var service = CreateService(handler, azureService); + if (credentialStage) + { + azureService.GetTokenCredentialAsync(Arg.Any(), Arg.Any()) + .Returns(_ => + { + cancellation.Cancel(); + return Task.FromCanceled(cancellation.Token); + }); + } + else + { + azureService.GetSubscription(Subscription, null, Arg.Any()) + .Returns(_ => + { + cancellation.Cancel(); + return Task.FromCanceled(cancellation.Token); + }); + } + + await Assert.ThrowsAnyAsync(async () => + { + if (health) + { + await service.GetRoutingEndpointHealth( + "hub1", "rg1", Subscription, cancellationToken: cancellation.Token); + } + else + { + await DiagnoseAsync(service, cancellation.Token); + } + }); + Assert.Empty(handler.RequestUris); + } + + [Theory] + [InlineData(false, "hub1", "rg1/../rg2", "resourceGroup")] + [InlineData(false, "hub1", @"rg1\..\rg2", "resourceGroup")] + [InlineData(false, "hub1", "..", "resourceGroup")] + [InlineData(false, "hub1/../hub2", "rg1", "hubName")] + [InlineData(true, "hub1", "rg1/../rg2", "resourceGroup")] + [InlineData(true, "hub1", @"rg1\..\rg2", "resourceGroup")] + [InlineData(true, "hub1", "..", "resourceGroup")] + [InlineData(true, "hub1/../hub2", "rg1", "hubName")] + public async Task RoutingOperations_RejectInvalidResourceNamesBeforeAnyRequest( + bool health, string hubName, string resourceGroup, string parameterName) + { + using var handler = CreateHandler((request, _) => Metrics(Metric(GetQuery(request, "metricnames")))); + var azureService = AzureServiceTestHelpers.CreateAzureService(); + var service = CreateService(handler, azureService); + + var exception = await Record.ExceptionAsync(async () => + { + if (health) + { + await service.GetRoutingEndpointHealth( + hubName, resourceGroup, Subscription, cancellationToken: TestContext.Current.CancellationToken); + } + else + { + await service.GetRoutingEndpointDiagnostics( + hubName, resourceGroup, Subscription, null, s_startTime, s_startTime.AddHours(1), "PT1H", + cancellationToken: TestContext.Current.CancellationToken); + } + }); + + // URI normalization would otherwise turn these values into a request for a different resource group or hub. + Assert.Empty(handler.RequestUris); + Assert.Equal(parameterName, Assert.IsType(exception).ParamName); + await azureService.DidNotReceive().GetSubscription(Arg.Any(), Arg.Any(), Arg.Any()); + await azureService.DidNotReceive().GetTokenCredentialAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [InlineData("RoutingDeliveries")] + [InlineData("RoutingDeliveryLatency")] + [InlineData("targetMetrics")] + [InlineData("existence")] + public async Task Diagnostics_PropagatesCancellationWithoutFallback(string stage) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var handler = CreateHandler((request, _) => + { + var name = GetQuery(request, "metricnames"); + if (name == stage || (stage == "targetMetrics" && !IsHubMetric(request))) + { + cancellation.Cancel(); + cancellation.Token.ThrowIfCancellationRequested(); + } + return Metrics(); + }, eventHub: true, existenceResponse: (_, _) => + { + if (stage == "existence") + { + cancellation.Cancel(); + cancellation.Token.ThrowIfCancellationRequested(); + } + return JsonResponse("{}"); + }); + + await Assert.ThrowsAnyAsync(() => + DiagnoseAsync(CreateService(handler), cancellationToken: cancellation.Token)); + + Assert.True(TargetMetricRequests(handler).Count() <= 1); + } + + [Theory] + [InlineData(HttpStatusCode.OK, "exists")] + [InlineData(HttpStatusCode.NotFound, "notFound")] + [InlineData(HttpStatusCode.Forbidden, "indeterminate")] + public async Task Diagnostics_QueriesRoutedResourceWithArmAuthentication( + HttpStatusCode statusCode, string expected) + { + var requests = 0; + using var handler = CreateHandler( + (request, _) => Metrics(Metric(GetQuery(request, "metricnames"))), + existenceResponse: (request, _) => + { + requests++; + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal( + $"https://management.azure.com/subscriptions/{Subscription}/resourceGroups/rg1/providers/Microsoft.Storage/storageAccounts/shared/blobServices/default/containers/container1?api-version=2023-05-01", + request.RequestUri!.ToString()); + Assert.Equal("Bearer", request.Headers.Authorization?.Scheme); + Assert.Equal("fake-token", request.Headers.Authorization?.Parameter); + return JsonResponse("""{"error":{"code":"AuthorizationFailed"}}""", statusCode); + }); + + var endpoint = Assert.Single((await DiagnoseAsync( + CreateService(handler), TestContext.Current.CancellationToken)).Endpoints); + + Assert.Equal(expected, endpoint.Target.ExistenceStatus); + Assert.Equal(1, requests); + } + + [Theory] + [InlineData(HttpStatusCode.TooManyRequests)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task Diagnostics_RetriesTransientExistenceFailures(HttpStatusCode statusCode) + { + var attempts = 0; + using var handler = CreateHandler( + (request, _) => Metrics(Metric(GetQuery(request, "metricnames"))), + existenceResponse: (_, _) => + { + var response = ++attempts == 1 + ? JsonResponse("""{"error":{"code":"ServerBusy"}}""", statusCode) + : JsonResponse("{}"); + response.Headers.Add("x-ms-retry-after-ms", "1"); + return response; + }); + + var endpoint = Assert.Single((await DiagnoseAsync( + CreateService(handler), TestContext.Current.CancellationToken)).Endpoints); + + Assert.Equal("exists", endpoint.Target.ExistenceStatus); + Assert.Null(endpoint.Target.Error); + Assert.Equal(2, attempts); + } + + [Theory] + [InlineData(HttpStatusCode.NotFound, "notFound")] + [InlineData(HttpStatusCode.Forbidden, "indeterminate")] + public async Task Diagnostics_SeparatesExistenceFromSharedParentMetrics( + HttpStatusCode existenceCode, string existenceStatus) + { + var before = DateTimeOffset.UtcNow; + using var handler = CreateHandler( + (request, _) => Metrics(Metric(GetQuery(request, "metricnames"))), + endpointCount: 2, + existenceResponse: (request, _) => request.RequestUri!.AbsolutePath.EndsWith("/container1", StringComparison.Ordinal) + ? JsonResponse("""{"error":{"code":"AuthorizationFailed"}}""", existenceCode) + : JsonResponse("{}")); + + var result = await DiagnoseAsync(CreateService(handler), TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Endpoints.Count); + var first = result.Endpoints[0]; + var second = result.Endpoints[1]; + Assert.Equal(existenceStatus, first.Target.ExistenceStatus); + Assert.Equal("exists", second.Target.ExistenceStatus); + Assert.InRange(DateTimeOffset.Parse(first.Target.ExistenceObservedAt!), before, DateTimeOffset.UtcNow); + Assert.Equal("parentResource", first.TargetEmitted.MetricScope); + Assert.Equal("queried", first.TargetEmitted.QueryStatus); + Assert.Empty(first.TargetEmitted.Errors); + Assert.Same(first.TargetEmitted, second.TargetEmitted); + Assert.Single(TargetMetricRequests(handler)); + Assert.Null(second.Target.Error); + if (existenceCode == HttpStatusCode.Forbidden) + { + Assert.NotNull(first.Target.Error); + Assert.Equal("targetArm", first.Target.Error.Source); + Assert.Equal(403, first.Target.Error.StatusCode); + } + else + { + Assert.Null(first.Target.Error); + } + } + + [Fact] + public async Task Diagnostics_ReportsIndeterminateExistenceAfterDefaultRetriesAreExhausted() + { + var attempts = 0; + using var handler = CreateHandler( + (request, _) => Metrics(Metric(GetQuery(request, "metricnames"))), + existenceResponse: (_, _) => + { + attempts++; + var response = JsonResponse( + """{"error":{"code":"ServerBusy"}}""", HttpStatusCode.ServiceUnavailable); + response.Headers.Add("x-ms-retry-after-ms", "1"); + return response; + }); + + var endpoint = Assert.Single((await DiagnoseAsync( + CreateService(handler), TestContext.Current.CancellationToken)).Endpoints); + + Assert.Equal(new ArmClientOptions().Retry.MaxRetries + 1, attempts); + Assert.Equal("indeterminate", endpoint.Target.ExistenceStatus); + Assert.NotNull(endpoint.Target.Error); + Assert.Equal(503, endpoint.Target.Error.StatusCode); + Assert.Equal("ServerBusy", endpoint.Target.Error.Code); + Assert.Equal("queried", endpoint.TargetEmitted.QueryStatus); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Diagnostics_LeavesKeyBasedTargetUnresolvedWithoutEndpointUri(bool eventHub) + { + using var handler = CreateHandler( + (request, _) => Metrics(Metric(GetQuery(request, "metricnames"))), + eventHub, + existenceResponse: (_, _) => JsonResponse( + """{"error":{"code":"ResourceNotFound"}}""", HttpStatusCode.NotFound), + keyBased: true); + + var endpoint = Assert.Single((await DiagnoseAsync( + CreateService(handler), TestContext.Current.CancellationToken)).Endpoints); + + Assert.Null(endpoint.EndpointResourceName); + Assert.Equal("unresolved", endpoint.Target.ResolutionStatus); + Assert.Equal("notChecked", endpoint.Target.ExistenceStatus); + Assert.Null(endpoint.Target.ResourceId); + Assert.Equal("unresolved", endpoint.TargetEmitted.QueryStatus); + Assert.Empty(endpoint.HubEmitted.Errors); + Assert.Contains(handler.RequestUris, uri => uri.AbsolutePath.Contains("/IotHubs/", StringComparison.Ordinal) + && uri.AbsolutePath.EndsWith("/metrics", StringComparison.Ordinal)); + Assert.All(handler.RequestUris, uri => Assert.Contains( + "/providers/Microsoft.Devices", uri.AbsolutePath, StringComparison.OrdinalIgnoreCase)); + } + + [Theory] + [InlineData(Subscription, "rg1/../rg2", "container1")] + [InlineData(Subscription, "..", "container1")] + [InlineData("not-a-subscription-id", "rg1", "container1")] + [InlineData(Subscription, "rg1", "..")] + [InlineData(Subscription, "rg1", "a/b")] + public async Task Diagnostics_LeavesTargetUnresolvedWhenEndpointNamesAreNotSingleSegments( + string endpointSubscription, string endpointResourceGroup, string endpointContainerName) + { + using var handler = CreateHandler( + (request, _) => Metrics(Metric(GetQuery(request, "metricnames"))), + endpointSubscription: endpointSubscription, + endpointResourceGroup: endpointResourceGroup, + endpointContainerName: endpointContainerName); + + var endpoint = Assert.Single((await DiagnoseAsync( + CreateService(handler), TestContext.Current.CancellationToken)).Endpoints); + + Assert.Equal("unresolved", endpoint.Target.ResolutionStatus); + Assert.Equal("notChecked", endpoint.Target.ExistenceStatus); + Assert.Equal("unresolved", endpoint.TargetEmitted.QueryStatus); + Assert.All(handler.RequestUris, uri => Assert.Contains( + "/providers/Microsoft.Devices", uri.AbsolutePath, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task Diagnostics_FallsBackAfterBatchRequestFailureAndKeepsEmptySuccess() + { + using var handler = CreateHandler((request, _) => + { + var name = GetQuery(request, "metricnames"); + if (!IsHubMetric(request) && (name.Contains(',') || name == "ServerErrors")) + { + return JsonResponse("""{"error":{"code":"BadRequest","message":"Unsupported metric"}}""", + HttpStatusCode.BadRequest); + } + return Metrics(Metric(name)); + }, eventHub: true); + + var endpoint = Assert.Single((await DiagnoseAsync(CreateService(handler), TestContext.Current.CancellationToken)).Endpoints); + + Assert.Equal("partial", endpoint.TargetEmitted.QueryStatus); + Assert.Equal(6, TargetMetricRequests(handler).Count()); + Assert.Equal("failed", endpoint.TargetEmitted.MetricAvailability["ServerErrors"]); + Assert.Equal("noValuesReturned", endpoint.TargetEmitted.MetricAvailability["SuccessfulRequests"]); + Assert.Single(endpoint.TargetEmitted.Errors); + } + + [Theory] + [InlineData(HttpStatusCode.TooManyRequests)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task Diagnostics_DoesNotFallBackAfterTransientBatchFailure(HttpStatusCode statusCode) + { + using var handler = CreateHandler((request, _) => + { + if (IsHubMetric(request)) + { + return Metrics(Metric(GetQuery(request, "metricnames"))); + } + var response = JsonResponse("""{"error":{"code":"ServerBusy"}}""", statusCode); + response.Headers.Add("x-ms-retry-after-ms", "1"); + return response; + }, eventHub: true); + + var endpoint = Assert.Single((await DiagnoseAsync(CreateService(handler), TestContext.Current.CancellationToken)).Endpoints); + + Assert.Equal(new ArmClientOptions().Retry.MaxRetries + 1, TargetMetricRequests(handler).Count()); + Assert.All(TargetMetricRequests(handler), uri => Assert.Contains(',', GetQuery(uri, "metricnames"))); + Assert.Equal(5, endpoint.TargetEmitted.MetricAvailability.Count); + Assert.All(endpoint.TargetEmitted.MetricAvailability.Values, status => Assert.Equal("failed", status)); + Assert.Equal("failed", endpoint.TargetEmitted.QueryStatus); + } + + [Fact] + public async Task Diagnostics_CancellationDuringFallbackStopsRemainingQueries() + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var handler = CreateHandler((request, _) => + { + if (IsHubMetric(request)) + { + return Metrics(); + } + if (GetQuery(request, "metricnames").Contains(',')) + { + return JsonResponse("""{"error":{"code":"BadRequest"}}""", HttpStatusCode.BadRequest); + } + cancellation.Cancel(); + cancellation.Token.ThrowIfCancellationRequested(); + return Metrics(); + }, eventHub: true); + + await Assert.ThrowsAnyAsync(() => + DiagnoseAsync(CreateService(handler), cancellationToken: cancellation.Token)); + + Assert.Equal(2, TargetMetricRequests(handler).Count()); + } + + private static Task DiagnoseAsync( + IoTHubRoutingService service, + CancellationToken cancellationToken, + string? endpointName = null) => + service.GetRoutingEndpointDiagnostics( + "hub1", "rg1", Subscription, endpointName, s_startTime, s_startTime.AddHours(1), "PT1H", + cancellationToken: cancellationToken); + + private static IEnumerable TargetMetricRequests(RoutingDiagnosticsHttpMessageHandler handler) => + handler.RequestUris.Where(uri => uri.AbsolutePath.EndsWith("/metrics", StringComparison.Ordinal) + && !uri.AbsolutePath.Contains("/IotHubs/", StringComparison.Ordinal)); + + private static bool IsHubMetric(HttpRequestMessage request) => + request.RequestUri!.AbsolutePath.Contains("/IotHubs/", StringComparison.Ordinal); + + private static string GetQuery(HttpRequestMessage request, string key) => GetQuery(request.RequestUri!, key); + + private static string GetQuery(Uri uri, string key) => + uri.Query.TrimStart('?').Split('&') + .Select(pair => pair.Split('=', 2)) + .Where(pair => Uri.UnescapeDataString(pair[0]) == key) + .Select(pair => Uri.UnescapeDataString(pair[1])) + .SingleOrDefault() ?? ""; + + private static HttpResponseMessage Metrics(params string[] metrics) => + JsonResponse($$"""{"timespan":"2026-08-01T00:00:00Z/2026-08-01T01:00:00Z","interval":"PT1H","value":[{{string.Join(",", metrics)}}]}"""); + + private static string Metric(string name, string series = "", string errorCode = "Success", string errorMessage = "") => + $$"""{"id":"{{HubId}}/providers/Microsoft.Insights/metrics/{{name}}","type":"Microsoft.Insights/metrics","name":{"value":"{{name}}"},"unit":"Count","timeseries":[{{series}}],"errorCode":"{{errorCode}}","errorMessage":"{{errorMessage}}"}"""; + + private static string Series(int count, bool hub, bool distinctDimensions = true, string aggregation = "total", string value = "1") => + string.Join(",", Enumerable.Range(0, count).Select(index => + { + var dimension = hub + ? $$"""{"name":{"value":"EndpointName"},"value":"endpoint1"},{"name":{"value":"FailureReasonCategory"},"value":"reason{{(distinctDimensions ? index : 0)}}"}""" + : $$"""{"name":{"value":"ResponseType"},"value":"response{{(distinctDimensions ? index : 0)}}"}"""; + return $$"""{"metadatavalues":[{{dimension}}],"data":[{"timeStamp":"2026-08-01T00:00:00Z","{{aggregation}}":{{value}}}]}"""; + })); + + private static HttpResponseMessage JsonResponse(string content, HttpStatusCode statusCode = HttpStatusCode.OK) => + new(statusCode) { Content = new StringContent(content, System.Text.Encoding.UTF8, "application/json") }; + + private static RoutingDiagnosticsHttpMessageHandler CreateHandler( + Func metricResponse, + bool eventHub = false, + int endpointCount = 1, + Func? existenceResponse = null, + bool keyBased = false, + string endpointSubscription = Subscription, + string endpointResourceGroup = "rg1", + string? endpointContainerName = null) => + new((request, token) => + { + var path = request.RequestUri!.AbsolutePath; + if (path.EndsWith("/metrics", StringComparison.Ordinal)) + { + return metricResponse(request, token); + } + if (path.EndsWith("/providers/Microsoft.Devices", StringComparison.OrdinalIgnoreCase)) + { + return JsonResponse($$"""{"id":"{{path}}","namespace":"Microsoft.Devices","registrationState":"Registered","resourceTypes":[{"resourceType":"IotHubs","locations":["westus"],"apiVersions":["2023-06-30"]}]}"""); + } + if (path.EndsWith("/hub1", StringComparison.Ordinal)) + { + // Key-based endpoints have an opaque id and no endpointUri; the target is only in the unread connection string. + var targetProperties = keyBased + ? "\"id\":\"dbc3cb4d-0119-4d46-9824-e8e3e83fb04a\",\"authenticationType\":\"keyBased\"" + : $"\"endpointUri\":\"{(eventHub ? "sb://shared.servicebus.windows.net/" : "https://shared.blob.core.windows.net/")}\""; + var endpoints = string.Join(",", Enumerable.Range(1, endpointCount).Select(index => + $$"""{"name":"endpoint{{index}}","subscriptionId":"{{endpointSubscription}}","resourceGroup":"{{endpointResourceGroup}}",{{targetProperties}},"entityPath":"eventhub{{index}}","containerName":"{{endpointContainerName ?? $"container{index}"}}"}""")); + return JsonResponse($$"""{"id":"{{HubId}}","name":"hub1","type":"Microsoft.Devices/IotHubs","location":"westus","properties":{"routing":{"endpoints":{"{{(eventHub ? "eventHubs" : "storageContainers")}}":[{{endpoints}}]} } } }"""); + } + return existenceResponse?.Invoke(request, token) ?? JsonResponse("{}"); + }); + + private static IoTHubRoutingService CreateService(HttpMessageHandler handler, IAzureService? azureService = null) + { + azureService ??= AzureServiceTestHelpers.CreateAzureService(); + var configuration = Substitute.For(); + configuration.ArmEnvironment.Returns(ArmEnvironment.AzurePublicCloud); + azureService.CloudConfiguration.Returns(configuration); + var credential = Substitute.For(); + credential.GetTokenAsync(Arg.Any(), Arg.Any()) + .Returns(new AccessToken("fake-token", DateTimeOffset.UtcNow.AddHours(1))); + azureService.GetTokenCredentialAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(credential)); + AzureServiceTestHelpers.ConfigureArmClientOptions( + azureService, new HttpClient(handler, disposeHandler: false), ArmEnvironment.AzurePublicCloud); + var subscription = Substitute.For(); + subscription.Data.Returns(ResourceManagerModelFactory.SubscriptionData(subscriptionId: Subscription)); + azureService.GetSubscription(Subscription, null, Arg.Any()).Returns(subscription); + var factory = Substitute.For(); + factory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(handler, disposeHandler: false)); + return new IoTHubRoutingService(azureService, factory, Substitute.For>()); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/IoTHubRoutingServiceTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/IoTHubRoutingServiceTests.cs new file mode 100644 index 000000000..4ee19f812 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/IoTHubRoutingServiceTests.cs @@ -0,0 +1,315 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Security; +using Azure.Core; +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Tests.Helpers; +using Azure.Mcp.Tools.IoTHub.Services; +using Azure.ResourceManager; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Services.Azure.Authentication; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.Tests.Services; + +public class IoTHubRoutingServiceTests() +{ + [Fact] + public async Task GetRoutingEndpointsHealthAsync_FollowsNextLink() + { + const string nextLink = "https://management.azure.com/next-health-page?api-version=2023-06-30"; + using var handler = new SequenceHttpMessageHandler( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + $"{{\"value\":[{{\"endpointId\":\"id1\",\"healthStatus\":\"healthy\"}}],\"nextLink\":\"{nextLink}\"}}") + }, + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """{"value":[{"endpointId":"id2","healthStatus":"unhealthy"}]}""") + }); + var service = CreateService(handler); + + var result = await service.GetRoutingEndpointsHealthAsync( + new ResourceIdentifier( + "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Devices/IotHubs/hub1"), + tenant: null, + TestContext.Current.CancellationToken); + + Assert.Collection( + result, + item => + { + Assert.Equal("id1", item.EndpointId); + Assert.Equal("healthy", item.HealthStatus); + }, + item => + { + Assert.Equal("id2", item.EndpointId); + Assert.Equal("unhealthy", item.HealthStatus); + }); + Assert.Equal(2, handler.RequestUris.Count); + Assert.Equal(nextLink, handler.RequestUris[1]?.ToString()); + } + + [Fact] + public void BuildMonitorMetricsOptions_AppliesRequestedIntervalAndWindow() + { + var startTime = new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.Zero); + var window = new ObservationWindow(startTime, startTime.AddHours(2)); + var interval = TimeSpan.FromMinutes(15); + + var options = IoTHubRoutingService.BuildMonitorMetricsOptions( + "Transactions", + "Microsoft.Storage/storageAccounts", + window, + interval, + "Total", + "ResponseType eq '*'"); + + Assert.Equal("Transactions", options.Metricnames); + Assert.Equal("Microsoft.Storage/storageAccounts", options.Metricnamespace); + Assert.Equal(window.Timespan, options.Timespan); + Assert.Equal(interval, options.Interval); + Assert.Equal("Total", options.Aggregation); + Assert.Equal("ResponseType eq '*'", options.Filter); + Assert.Equal(IoTHubRoutingService.MetricSeriesLimit, options.Top); + } + + [Fact] + public void BuildMonitorMetricsOptions_OmitsFilterWhenNotProvided() + { + var startTime = new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.Zero); + var window = new ObservationWindow(startTime, startTime.AddHours(6)); + var interval = TimeSpan.FromHours(6); + + var options = IoTHubRoutingService.BuildMonitorMetricsOptions( + "SuccessfulRequests,ServerErrors", + "Microsoft.EventHub/namespaces", + window, + interval, + "Total"); + + Assert.Equal(interval, options.Interval); + Assert.Null(options.Filter); + Assert.Null(options.Top); + } + + [Theory] + [InlineData(HttpStatusCode.BadRequest, "ResourceNotFound")] + [InlineData(HttpStatusCode.BadRequest, "ParentResourceNotFound")] + [InlineData(HttpStatusCode.BadRequest, "EntityNotFound")] + [InlineData(HttpStatusCode.BadRequest, "MessagingEntityNotFound")] + [InlineData(HttpStatusCode.BadRequest, "ContainerNotFound")] + [InlineData(HttpStatusCode.BadRequest, "NotFound")] + [InlineData(HttpStatusCode.NotFound, "Unrecognized")] + public void IsResourceNotFoundResponse_RecognizesAzureNotFoundResponses( + HttpStatusCode statusCode, + string errorCode) + { + var responseContent = + $"{{\"error\":{{\"code\":\"{errorCode}\",\"message\":\"The resource does not exist.\"}}}}"; + + Assert.True(IoTHubRoutingService.IsResourceNotFoundResponse(statusCode, responseContent)); + } + + [Theory] + [InlineData(HttpStatusCode.Forbidden, """{"error":{"code":"AuthorizationFailed"}}""")] + [InlineData(HttpStatusCode.BadRequest, """{"error":{"code":"InvalidRequest"}}""")] + [InlineData(HttpStatusCode.InternalServerError, "not-json")] + public void IsResourceNotFoundResponse_RejectsOtherResponses( + HttpStatusCode statusCode, + string responseContent) + { + Assert.False(IoTHubRoutingService.IsResourceNotFoundResponse(statusCode, responseContent)); + } + + [Theory] + [InlineData(HttpStatusCode.TooManyRequests)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task GetRoutingEndpointsHealthAsync_RetriesTransientFailures(HttpStatusCode statusCode) + { + var retryResponse = new HttpResponseMessage(statusCode) { Content = new StringContent("{}") }; + retryResponse.Headers.Add("x-ms-retry-after-ms", "1"); + using var handler = new SequenceHttpMessageHandler( + retryResponse, + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("""{"value":[]}""") }); + + var result = await CreateService(handler).GetRoutingEndpointsHealthAsync( + new ResourceIdentifier("/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Devices/IotHubs/hub1"), + null, + TestContext.Current.CancellationToken); + + Assert.Empty(result); + Assert.Equal(2, handler.RequestUris.Count); + } + + [Theory] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.NotFound)] + public async Task GetRoutingEndpointsHealthAsync_DoesNotRetryPermanentFailures(HttpStatusCode statusCode) + { + using var handler = new SequenceHttpMessageHandler( + new HttpResponseMessage(statusCode) { Content = new StringContent("{}") }); + + var error = await Assert.ThrowsAsync(() => + CreateService(handler).GetRoutingEndpointsHealthAsync( + new ResourceIdentifier("/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Devices/IotHubs/hub1"), + null, + TestContext.Current.CancellationToken)); + + Assert.Equal((int)statusCode, error.Status); + Assert.Single(handler.RequestUris); + } + + [Fact] + public async Task ExecuteWithTimeoutAsync_ReportsExpiredBudget() + { + var error = await Assert.ThrowsAsync(() => + IoTHubRoutingService.ExecuteWithTimeoutAsync( + async token => + { + await Task.Delay(Timeout.InfiniteTimeSpan, token); + return 0; + }, + "routing test", + TestContext.Current.CancellationToken, + TimeSpan.FromMilliseconds(20))); + + Assert.Contains("routing test", error.Message); + } + + [Fact] + public async Task ExecuteWithTimeoutAsync_PreservesCallerCancellation() + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => + IoTHubRoutingService.ExecuteWithTimeoutAsync( + async token => + { + await Task.Delay(Timeout.InfiniteTimeSpan, token); + return 0; + }, + "routing test", + cancellation.Token)); + } + + [Fact] + public async Task GetRoutingEndpointsHealthAsync_UsesConfiguredCloudAndTenant() + { + var azureService = AzureServiceTestHelpers.CreateAzureService(); + var credential = Substitute.For(); + using var handler = new SequenceHttpMessageHandler( + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("""{"value":[]}""") }); + var service = CreateService(handler, azureService, ArmEnvironment.AzureGovernment, credential); + azureService.ResolveTenantIdAsync("tenant-alias", Arg.Any()).Returns("tenant-id"); + + await service.GetRoutingEndpointsHealthAsync( + new ResourceIdentifier("/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Devices/IotHubs/hub1"), + "tenant-alias", + TestContext.Current.CancellationToken); + + var request = Assert.Single(handler.RequestUris); + Assert.Equal(ArmEnvironment.AzureGovernment.Endpoint.Host, request!.Host); + await azureService.Received(1).GetTokenCredentialAsync("tenant-id", Arg.Any()); + await credential.Received().GetTokenAsync( + Arg.Is(context => + context.Scopes.Length == 1 && context.Scopes[0] == ArmEnvironment.AzureGovernment.DefaultScope), + Arg.Any()); + } + + [Fact] + public async Task GetRoutingEndpointsHealthAsync_RejectsForeignNextLinkBeforeResolvingCredential() + { + var azureService = AzureServiceTestHelpers.CreateAzureService(); + using var handler = new SequenceHttpMessageHandler( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"value":[],"nextLink":"https://example.com/next-health-page"}""") + }); + var service = CreateService(handler, azureService); + + await Assert.ThrowsAsync(() => + service.GetRoutingEndpointsHealthAsync( + new ResourceIdentifier("/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Devices/IotHubs/hub1"), + null, + TestContext.Current.CancellationToken)); + + Assert.Single(handler.RequestUris); + await azureService.Received(1).GetTokenCredentialAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public void BuildArmRequestUri_ResolvesResourcePathAgainstArmEndpoint() + { + var uri = IoTHubRoutingService.BuildArmRequestUri( + AzureServiceTestHelpers.CreateAzureService(armEnvironment: ArmEnvironment.AzureGovernment), + "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Devices/IotHubs/hub1/routingEndpointsHealth", + "2023-06-30"); + + Assert.Equal(Uri.UriSchemeHttps, uri.Scheme); + Assert.Equal(ArmEnvironment.AzureGovernment.Endpoint.Host, uri.Host); + Assert.Equal( + "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Devices/IotHubs/hub1/routingEndpointsHealth", + uri.AbsolutePath); + Assert.Equal("?api-version=2023-06-30", uri.Query); + } + + [Fact] + public void BuildArmRequestUri_UsesArmNextLinkAsReturned() + { + const string nextLink = + "https://management.azure.com/subscriptions/sub1/next?api-version=2023-06-30&$skiptoken=abc"; + + var uri = IoTHubRoutingService.BuildArmRequestUri( + AzureServiceTestHelpers.CreateAzureService(armEnvironment: ArmEnvironment.AzurePublicCloud), + nextLink, + apiVersion: null); + + Assert.Equal(nextLink, uri.AbsoluteUri); + } + + [Theory] + [InlineData("file:///subscriptions/sub1/resourceGroups/rg1")] + [InlineData("http://management.azure.com/subscriptions/sub1/next")] + [InlineData("https://example.com/subscriptions/sub1/next")] + [InlineData("https://management.usgovcloudapi.net/subscriptions/sub1/next")] + public void BuildArmRequestUri_RejectsLinksOutsideArmEndpoint(string link) + { + Assert.Throws(() => + IoTHubRoutingService.BuildArmRequestUri( + AzureServiceTestHelpers.CreateAzureService(armEnvironment: ArmEnvironment.AzurePublicCloud), + link, + apiVersion: null)); + } + + private static IoTHubRoutingService CreateService( + HttpMessageHandler handler, + IAzureService? azureService = null, + ArmEnvironment? environment = null, + TokenCredential? credential = null) + { + azureService ??= AzureServiceTestHelpers.CreateAzureService(); + var logger = Substitute.For>(); + + var cloudConfiguration = Substitute.For(); + cloudConfiguration.ArmEnvironment.Returns(environment ?? ArmEnvironment.AzurePublicCloud); + azureService.CloudConfiguration.Returns(cloudConfiguration); + + credential ??= Substitute.For(); + credential.GetTokenAsync(Arg.Any(), Arg.Any()) + .Returns(new AccessToken("fake-token", DateTimeOffset.UtcNow.AddHours(1))); + azureService.GetTokenCredentialAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(credential)); + var factory = Substitute.For(); + factory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(handler, disposeHandler: false)); + + return new IoTHubRoutingService(azureService, factory, logger); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/IoTHubServiceTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/IoTHubServiceTests.cs new file mode 100644 index 000000000..aef930c9f --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/IoTHubServiceTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Core; +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Tests.Helpers; +using Azure.Mcp.Tools.IoTHub.Services; +using Azure.ResourceManager; +using Azure.ResourceManager.Models; +using Azure.ResourceManager.Resources; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.Tests.Services; + +public class IoTHubServiceTests() +{ + private const string Subscription = "11111111-1111-1111-1111-111111111111"; + + [Fact] + public async Task GetIoTHub_ReadsHubFromRequestedResourceGroup() + { + using var handler = CreateHandler(); + var service = CreateService(handler, AzureServiceTestHelpers.CreateAzureService()); + + var hub = await service.GetIoTHub("hub1", "rg1", Subscription, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("hub1", hub.Name); + Assert.Contains( + handler.RequestUris, + uri => uri.AbsolutePath == $"/subscriptions/{Subscription}/resourceGroups/rg1/providers/Microsoft.Devices/IotHubs/hub1"); + } + + [Theory] + [InlineData("hub1", "rg1/../rg2", "resourceGroup")] + [InlineData("hub1", @"rg1\..\rg2", "resourceGroup")] + [InlineData("hub1", "..", "resourceGroup")] + [InlineData("hub1/../hub2", "rg1", "hubName")] + public async Task GetIoTHub_RejectsInvalidResourceNamesBeforeAnyRequest( + string hubName, string resourceGroup, string parameterName) + { + using var handler = CreateHandler(); + var azureService = AzureServiceTestHelpers.CreateAzureService(); + var service = CreateService(handler, azureService); + + var exception = await Record.ExceptionAsync(() => service.GetIoTHub( + hubName, resourceGroup, Subscription, cancellationToken: TestContext.Current.CancellationToken)); + + // URI normalization would otherwise turn these values into a request for a different resource group or hub. + Assert.Empty(handler.RequestUris); + Assert.Equal(parameterName, Assert.IsType(exception).ParamName); + await azureService.DidNotReceive().GetSubscription(Arg.Any(), Arg.Any(), Arg.Any()); + await azureService.DidNotReceive().GetTokenCredentialAsync(Arg.Any(), Arg.Any()); + } + + private static RoutingDiagnosticsHttpMessageHandler CreateHandler() => + new((request, _) => + { + var path = request.RequestUri!.AbsolutePath; + var content = path.EndsWith("/providers/Microsoft.Devices", StringComparison.OrdinalIgnoreCase) + ? $$"""{"id":"{{path}}","namespace":"Microsoft.Devices","registrationState":"Registered","resourceTypes":[{"resourceType":"IotHubs","locations":["westus"],"apiVersions":["2023-06-30"]}]}""" + : $$"""{"id":"{{path}}","name":"{{path[(path.LastIndexOf('/') + 1)..]}}","type":"Microsoft.Devices/IotHubs","location":"westus","properties":{} }"""; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(content, System.Text.Encoding.UTF8, "application/json") + }; + }); + + private static IoTHubService CreateService(HttpMessageHandler handler, IAzureService azureService) + { + var credential = Substitute.For(); + credential.GetTokenAsync(Arg.Any(), Arg.Any()) + .Returns(new AccessToken("fake-token", DateTimeOffset.UtcNow.AddHours(1))); + azureService.GetTokenCredentialAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(credential)); + AzureServiceTestHelpers.ConfigureArmClientOptions( + azureService, new HttpClient(handler, disposeHandler: false), ArmEnvironment.AzurePublicCloud); + var subscription = Substitute.For(); + subscription.Data.Returns(ResourceManagerModelFactory.SubscriptionData(subscriptionId: Subscription)); + azureService.GetSubscription(Subscription, null, Arg.Any()).Returns(subscription); + return new IoTHubService(azureService, Substitute.For>()); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/RoutingDiagnosticsHttpMessageHandler.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/RoutingDiagnosticsHttpMessageHandler.cs new file mode 100644 index 000000000..f3e68ccdf --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/RoutingDiagnosticsHttpMessageHandler.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; + +namespace Azure.Mcp.Tools.IoTHub.Tests.Services; + +internal sealed class RoutingDiagnosticsHttpMessageHandler( + Func respond) : HttpMessageHandler +{ + public ConcurrentQueue RequestUris { get; } = new(); + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestUris.Enqueue(request.RequestUri!); + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(respond(request, cancellationToken)); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/SequenceHttpMessageHandler.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/SequenceHttpMessageHandler.cs new file mode 100644 index 000000000..18bd83d34 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/Services/SequenceHttpMessageHandler.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Tests.Services; + +internal sealed class SequenceHttpMessageHandler(params HttpResponseMessage[] responses) : HttpMessageHandler +{ + private readonly Queue _responses = new(responses); + + public List RequestUris { get; } = []; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestUris.Add(request.RequestUri); + return Task.FromResult(_responses.Dequeue()); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/assets.json b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/assets.json index 6bf6b3c55..40a4e6c94 100644 --- a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.Tests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.IoTHub.Tests", - "Tag": "Azure.Mcp.Tools.IoTHub.Tests_7c882c0908" + "Tag": "Azure.Mcp.Tools.IoTHub.Tests_a539293e6d" } diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/test-resources-post.ps1 b/tools/Azure.Mcp.Tools.IoTHub/tests/test-resources-post.ps1 index 1bac5af54..a8bbaf4d2 100644 --- a/tools/Azure.Mcp.Tools.IoTHub/tests/test-resources-post.ps1 +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/test-resources-post.ps1 @@ -14,51 +14,149 @@ $ErrorActionPreference = "Stop" $testSettings = New-TestSettings @PSBoundParameters -OutputPath $PSScriptRoot -Write-Host "Setting up IoT Hub test devices..." +function Get-PlainTextAccessToken { + param([Parameter(Mandatory)] [string] $ResourceUrl) -$iotHubName = $DeploymentOutputs['IOTHUB_NAME'] -Write-Host "IoT Hub Name: $iotHubName" + $token = (Get-AzAccessToken -ResourceUrl $ResourceUrl).Token + if ($token -is [System.Security.SecureString]) { + return [System.Net.NetworkCredential]::new('', $token).Password + } -# Ensure Azure IoT CLI extension is installed for `az iot` commands -Write-Host "Checking for the Azure IoT CLI extension..." -az extension show --name azure-iot --output table -if ($LASTEXITCODE -ne 0) { - Write-Host "Azure IoT CLI extension is not installed. Installing the preview version..." - az extension add --name azure-iot --allow-preview true --yes - if ($LASTEXITCODE -ne 0) { - # `az extension add` swallows pip's real stdout/stderr and only prints a generic - # "Pip failed with status code 1. Use --debug for more information." unless - # --debug is passed. Retry once with --debug so the actual pip error (network - # timeout, version conflict, disk space, etc.) is captured in the pipeline log - # instead of only this uninformative wrapper message. If the retry happens to - # succeed (e.g. a transient network blip), treat the extension as installed. - $initialExitCode = $LASTEXITCODE - Write-Host "##[warning]Initial install attempt failed (exit code $initialExitCode). Retrying with --debug to capture the underlying pip error..." - Write-Host "##[group]az extension add --debug output" - az extension add --name azure-iot --allow-preview true --yes --debug - $retryExitCode = $LASTEXITCODE - Write-Host "##[endgroup]" - if ($retryExitCode -ne 0) { - throw "Failed to install the Azure IoT CLI extension. Exit code: $retryExitCode (initial attempt: $initialExitCode)." + return $token +} + +function Invoke-IoTHubRegistryRequest { + param( + [Parameter(Mandatory)] [string] $Method, + [Parameter(Mandatory)] [string] $Uri, + [Parameter(Mandatory)] [string] $Body, + [Parameter(Mandatory)] [string] $AccessToken, + [int[]] $AcceptedStatusCodes = @() + ) + + $headers = @{ Authorization = "Bearer $AccessToken" } + $maxAttempts = 30 + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + try { + Invoke-RestMethod -Method $Method -Uri $Uri -Headers $headers -ContentType 'application/json' -Body $Body | Out-Null + return + } catch { + if ($_.Exception -isnot [Microsoft.PowerShell.Commands.HttpResponseException] -or $null -eq $_.Exception.Response) { + throw + } + + $statusCode = [int]$_.Exception.Response.StatusCode + if ($AcceptedStatusCodes -contains $statusCode) { + return + } + + # The IoT Hub Data Contributor assignment from test-resources.bicep can take several minutes to take effect. + $isTransient = $statusCode -eq 401 -or $statusCode -eq 403 -or $statusCode -eq 429 -or $statusCode -ge 500 + if (-not $isTransient -or $attempt -eq $maxAttempts) { + throw + } + + Write-Host "IoT Hub returned $statusCode for $Method $Uri (attempt $attempt of $maxAttempts); retrying in 20 seconds." + Start-Sleep -Seconds 20 } } } -Write-Host "Verifying the Azure IoT CLI extension installation..." -az extension show --name azure-iot --output table -if ($LASTEXITCODE -ne 0) { - throw "The Azure IoT CLI extension is unavailable after installation. Exit code: $LASTEXITCODE." +function Set-IoTHubRouting { + param( + [Parameter(Mandatory)] [string] $HubPath, + [Parameter(Mandatory)] [object[]] $ServiceBusQueues, + [Parameter(Mandatory)] [object[]] $ServiceBusTopics, + [Parameter(Mandatory)] [object[]] $Routes + ) + + $maxAttempts = 30 + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + $response = Invoke-AzRestMethod -Path $HubPath -Method GET + if ($response.StatusCode -ne 200) { + throw "Reading IoT Hub failed with status $($response.StatusCode): $($response.Content)" + } + + # IoT Hub has no PATCH for properties, so write back the full resource with the routing added. + $hub = $response.Content | ConvertFrom-Json -AsHashtable -Depth 64 + $hub.properties.routing.endpoints.serviceBusQueues = $ServiceBusQueues + $hub.properties.routing.endpoints.serviceBusTopics = $ServiceBusTopics + $hub.properties.routing.routes = $Routes + + $response = Invoke-AzRestMethod -Path $HubPath -Method PUT -Payload (ConvertTo-Json -InputObject $hub -Depth 64) + if ($response.StatusCode -in 200, 201) { + break + } + + # IoT Hub rejects identity-based endpoints (IH400117) until the Service Bus Data Sender assignment from + # test-resources.bicep takes effect, which can take several minutes. + if ($response.StatusCode -ne 400 -or $response.Content -notmatch '400117' -or $attempt -eq $maxAttempts) { + throw "Configuring IoT Hub routing failed with status $($response.StatusCode): $($response.Content)" + } + + Write-Host "IoT Hub cannot authenticate to the routing endpoints yet (attempt $attempt of $maxAttempts); retrying in 20 seconds." + Start-Sleep -Seconds 20 + } + + for ($poll = 1; $poll -le 60; $poll++) { + $hub = (Invoke-AzRestMethod -Path $HubPath -Method GET).Content | ConvertFrom-Json -AsHashtable -Depth 64 + switch ($hub.properties.provisioningState) { + 'Succeeded' { return } + 'Failed' { throw "The IoT Hub routing update failed." } + } + Start-Sleep -Seconds 10 + } + + throw "Timed out waiting for the IoT Hub routing update to finish." } +$iotHubName = $DeploymentOutputs['IOTHUB_NAME'] +$iotHubHostName = $DeploymentOutputs['IOTHUB_HOST_NAME'] +Write-Host "IoT Hub Name: $iotHubName" + +Write-Host "Configuring IoT Hub routing endpoints..." + +$routingEndpoint = @{ + endpointUri = $DeploymentOutputs['ROUTING_ENDPOINT_URI'] + subscriptionId = $testSettings.SubscriptionId + resourceGroup = $ResourceGroupName + authenticationType = 'identityBased' + identity = @{ userAssignedIdentity = $DeploymentOutputs['ROUTING_IDENTITY_ID'] } +} +$routes = foreach ($endpointName in 'sbqueue-endpoint', 'sbtopic-endpoint') { + @{ + name = $endpointName + source = 'DeviceMessages' + condition = 'true' + endpointNames = @($endpointName) + isEnabled = $true + } +} +Set-IoTHubRouting ` + -HubPath "/subscriptions/$($testSettings.SubscriptionId)/resourceGroups/$ResourceGroupName/providers/Microsoft.Devices/IotHubs/$iotHubName`?api-version=2023-06-30" ` + -ServiceBusQueues @($routingEndpoint + @{ name = 'sbqueue-endpoint'; entityPath = $DeploymentOutputs['ROUTING_QUEUE_NAME'] }) ` + -ServiceBusTopics @($routingEndpoint + @{ name = 'sbtopic-endpoint'; entityPath = $DeploymentOutputs['ROUTING_TOPIC_NAME'] }) ` + -Routes @($routes) +Write-Host "Configured IoT Hub routing endpoints" + +Write-Host "Setting up IoT Hub test devices..." + +# Use the registry REST API with Microsoft Entra ID, as the tools do. The azure-iot CLI extension used previously +# cannot be installed on network-isolated pipeline agents. +$accessToken = Get-PlainTextAccessToken -ResourceUrl 'https://iothubs.azure.net' +$apiVersion = '2021-04-12' + # Create test devices for device registry tests $testDevices = @('test-device-1', 'test-device-2', 'test-device-3') foreach ($deviceId in $testDevices) { try { - # Create device identity - az iot hub device-identity create --device-id $deviceId --hub-name $iotHubName --auth-type key - if ($LASTEXITCODE -ne 0) { - throw "az iot hub device-identity create failed with exit code $LASTEXITCODE." - } + # Create device identity; 409 means it already exists from an earlier run against the same hub. + $device = @{ + deviceId = $deviceId + authentication = @{ type = 'sas' } + } | ConvertTo-Json -Depth 10 + Invoke-IoTHubRegistryRequest -Method Put -Uri "https://$iotHubHostName/devices/$deviceId`?api-version=$apiVersion" ` + -Body $device -AccessToken $accessToken -AcceptedStatusCodes @(409) Write-Host "Created device: $deviceId" # Update device twin with test properties @@ -74,11 +172,8 @@ foreach ($deviceId in $testDevices) { deviceType = "sensor" } } | ConvertTo-Json -Depth 10 - - az iot hub device-twin update --device-id $deviceId --hub-name $iotHubName --set "$twinPatch" - if ($LASTEXITCODE -ne 0) { - throw "az iot hub device-twin update failed with exit code $LASTEXITCODE." - } + Invoke-IoTHubRegistryRequest -Method Patch -Uri "https://$iotHubHostName/twins/$deviceId`?api-version=$apiVersion" ` + -Body $twinPatch -AccessToken $accessToken Write-Host "Updated device twin for: $deviceId" } catch { @@ -86,12 +181,6 @@ foreach ($deviceId in $testDevices) { } } -# Native az invocations above may leave a stale non-zero $LASTEXITCODE even when the -# failures were already caught and logged as warnings. Reset it here so the Azure Pipelines -# task doesn't report this script as failed based on a leftover exit code from a handled, -# non-fatal az CLI error (e.g. a dynamic extension-install retry). -$LASTEXITCODE = 0 - Write-Host "IoT Hub test setup complete" diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/test-resources.bicep b/tools/Azure.Mcp.Tools.IoTHub/tests/test-resources.bicep index 075e2a3cd..ad693cdb2 100644 --- a/tools/Azure.Mcp.Tools.IoTHub/tests/test-resources.bicep +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/test-resources.bicep @@ -11,6 +11,49 @@ param testApplicationOid string = deployer().objectId @description('The location of the resource. By default, this is the same as the resource group.') param location string = resourceGroup().location +resource routingIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: '${baseName}-routing' + location: location +} + +resource routingNamespace 'Microsoft.ServiceBus/namespaces@2024-01-01' = { + name: '${baseName}-routing' + location: location + sku: { + name: 'Standard' + tier: 'Standard' + } + properties: { + disableLocalAuth: true + minimumTlsVersion: '1.2' + } +} + +resource routingQueue 'Microsoft.ServiceBus/namespaces/queues@2024-01-01' = { + parent: routingNamespace + name: 'telemetry-queue' +} + +resource routingTopic 'Microsoft.ServiceBus/namespaces/topics@2024-01-01' = { + parent: routingNamespace + name: 'telemetry' +} + +resource routingSender 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(routingNamespace.id, routingIdentity.id, 'sender') + scope: routingNamespace + properties: { + principalId: routingIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '69a216fc-b8fb-44d8-bc22-1f3c2cd27a39' + ) + } +} + +// Routing endpoints and routes are added by test-resources-post.ps1. IoT Hub validates identity-based endpoints +// when they are created, and ARM cannot wait for the routingSender assignment above to take effect. resource iotHub 'Microsoft.Devices/IotHubs@2023-06-30' = { name: baseName location: location @@ -18,7 +61,12 @@ resource iotHub 'Microsoft.Devices/IotHubs@2023-06-30' = { name: 'S1' capacity: 1 } - properties: {} + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${routingIdentity.id}': {} + } + } } // Control-plane Reader role: required to resolve the IoT Hub hostname via the ARM hub GET. @@ -37,20 +85,26 @@ resource readerRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-0 } } -// Data-plane IoT Hub Data Reader role: required to read the device registry over Microsoft Entra ID. -// See https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#iot-hub-data-reader -resource iotHubDataReaderRoleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = { +// Data-plane IoT Hub Data Contributor role: required to read the device registry over Microsoft Entra ID and to +// create the test devices in test-resources-post.ps1 without shared access keys or the azure-iot CLI extension. +// See https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#iot-hub-data-contributor +resource iotHubDataContributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = { scope: subscription() - name: 'b447c946-2db7-41ec-983d-d8bf3b1c77e3' + name: '4fc6c259-987e-4a07-842e-c321cc9d413f' } -resource iotHubDataReaderRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(iotHubDataReaderRoleDefinition.id, testApplicationOid, iotHub.id) +resource iotHubDataContributorRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(iotHubDataContributorRoleDefinition.id, testApplicationOid, iotHub.id) scope: iotHub properties: { principalId: testApplicationOid - roleDefinitionId: iotHubDataReaderRoleDefinition.id + roleDefinitionId: iotHubDataContributorRoleDefinition.id } } output IOTHUB_NAME string = iotHub.name +output IOTHUB_HOST_NAME string = iotHub.properties.hostName +output ROUTING_IDENTITY_ID string = routingIdentity.id +output ROUTING_ENDPOINT_URI string = 'sb://${routingNamespace.name}.servicebus.windows.net' +output ROUTING_QUEUE_NAME string = routingQueue.name +output ROUTING_TOPIC_NAME string = routingTopic.name