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>
This commit is contained in:
vineetkalkiandCopilot authored and GitHub committed 2026-10-28 11:00:48 -07:00
1 parent 729ac59d4a
commit b10fb6bfc8
60 files changed
+4883 -83

No files matched your search

+3 -1
View File
@@ -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
@@ -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."
@@ -3393,8 +3393,39 @@ azmcp iothub query run --subscription <subscription> \
[--from <source>] \
[--logical-operator <operator>] \
[--max-count <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 <subscription> \
--resource-group <resource-group> \
--hub-name <iot-hub-name> \
[--endpoint-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 <subscription> \
--resource-group <resource-group> \
--hub-name <iot-hub-name> \
[--endpoint-name <endpoint-name>] \
[--start-time <iso8601-datetime> --end-time <iso8601-datetime>] \
[--interval <iso8601-duration>]
```
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
@@ -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 <hub_name> in resource group <resource_group_name>; do not use the device-list operation | none |
| iothub_query_run | Find devices in IoT Hub <hub_name> where reported batteryLevel is less than 20 | none |
| iothub_query_run | Find devices in IoT Hub <hub_name> where tag environment equals 'production' | none |
| iothub_routing_endpoint-diagnostics | Show routing endpoint diagnostics evidence for IoT Hub <hub_name> in resource group <resource_group_name> | none |
| iothub_routing_endpoint-diagnostics | Get routing endpoint diagnostics metrics for IoT Hub <hub_name> from <start_time> through <end_time> | none |
| iothub_routing_endpoint-diagnostics | Check whether routing endpoint <endpoint_name> on IoT Hub <hub_name> 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 <hub_name> without assuming the hub is idle or healthy | none |
| iothub_routing_endpoint-health | Show routing endpoint health for IoT Hub <hub_name> in resource group <resource_group_name> | none |
| iothub_routing_endpoint-health | Get the current routing endpoint health for endpoint <endpoint_name> on IoT Hub <hub_name> | none |
## Azure IoT Operations
@@ -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.",
+6
View File
@@ -10,4 +10,10 @@ words:
- brien
- detag
- iothub
- lookback
- metadatavalues
- metricnames
- metricnamespace
- notjson
- sbqueue
- sbtopic
@@ -14,6 +14,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.ResourceManager" />
<PackageReference Include="Azure.ResourceManager.Monitor" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
@@ -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<CommandResponse> 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;
}
}
@@ -29,6 +29,22 @@ namespace Azure.Mcp.Tools.IoTHub.Commands;
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(List<JsonElement>))]
[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)]
@@ -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;
}
}
@@ -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<IoTHubRoutingEndpointDiagnosticsCommand> logger,
IIoTHubRoutingService service,
ISubscriptionResolver subscriptionResolver)
: BaseIoTHubCommand<IoTHubRoutingEndpointDiagnosticsOptions, RoutingEndpointDiagnostics>(subscriptionResolver)
{
private readonly ILogger<IoTHubRoutingEndpointDiagnosticsCommand> _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<CommandResponse> 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)
};
}
@@ -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<IoTHubRoutingEndpointHealthCommand> logger,
IIoTHubRoutingService service,
ISubscriptionResolver subscriptionResolver)
: BaseIoTHubCommand<IoTHubRoutingEndpointHealthOptions, RoutingEndpointHealthResult>(subscriptionResolver)
{
private readonly ILogger<IoTHubRoutingEndpointHealthCommand> _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<CommandResponse> 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)
};
}
@@ -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<IIoTHubService, IoTHubService>();
services.AddSingleton<IIoTHubHostnameResolver, IoTHubHostnameResolver>();
services.AddSingleton<IIoTHubDeviceService, IoTHubDeviceService>();
services.AddSingleton<IIoTHubRoutingService, IoTHubRoutingService>();
services.AddSingleton<IoTHubGetCommand>();
services.AddSingleton<IoTHubDeviceListCommand>();
@@ -29,6 +31,8 @@ public class IoTHubSetup : IAreaSetup
services.AddSingleton<IoTHubDeviceStatisticsCommand>();
services.AddSingleton<IoTHubDeviceTwinGetCommand>();
services.AddSingleton<IoTHubQueryRunCommand>();
services.AddSingleton<IoTHubRoutingEndpointHealthCommand>();
services.AddSingleton<IoTHubRoutingEndpointDiagnosticsCommand>();
}
public CommandGroup RegisterCommands(IServiceProvider serviceProvider)
@@ -55,6 +59,11 @@ public class IoTHubSetup : IAreaSetup
iothub.AddSubGroup(query);
query.AddCommand<IoTHubQueryRunCommand>(serviceProvider);
var routing = new CommandGroup("routing", "IoT Hub message-routing operations.");
iothub.AddSubGroup(routing);
routing.AddCommand<IoTHubRoutingEndpointHealthCommand>(serviceProvider);
routing.AddCommand<IoTHubRoutingEndpointDiagnosticsCommand>(serviceProvider);
return iothub;
}
}
@@ -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; }
}
@@ -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<EndpointHealthData>? Value { get; set; }
public string? NextLink { get; set; }
}
@@ -12,4 +12,5 @@ public record IoTHubDescription(
string Sku,
long Capacity,
string State,
string HostName);
string HostName,
IReadOnlyList<RoutingEndpointDetails>? RoutingEndpoints = null);
@@ -8,4 +8,6 @@ public class IoTHubProperties
public string? State { get; set; }
public string? HostName { get; set; }
public RoutingProperties? Routing { get; set; }
}
@@ -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);
@@ -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);
@@ -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);
@@ -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<RoutingEndpointDiagnostic> Endpoints);
@@ -0,0 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
namespace Azure.Mcp.Tools.IoTHub.Models;
public record RoutingEndpointHealthResult(
List<RoutingEndpointHealthSnapshot> Endpoints);
@@ -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);
@@ -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; }
}
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
namespace Azure.Mcp.Tools.IoTHub.Models;
public class RoutingEndpoints
{
public List<RoutingEndpointProperties>? EventHubs { get; set; }
public List<RoutingEndpointProperties>? ServiceBusQueues { get; set; }
public List<RoutingEndpointProperties>? ServiceBusTopics { get; set; }
public List<RoutingEndpointProperties>? StorageContainers { get; set; }
public List<RoutingEndpointProperties>? CosmosDBSqlContainers { get; set; }
}
@@ -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<RoutingDiagnosticError> Errors);
@@ -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<string, JsonElement> WindowAggregates,
List<Dictionary<string, JsonElement>> Buckets)
{
public Dictionary<string, string> MetricAvailability { get; init; } = [];
}
@@ -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);
@@ -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; }
}
@@ -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<string, JsonElement> WindowAggregates,
List<Dictionary<string, JsonElement>> Buckets,
List<RoutingDiagnosticError> Errors)
{
public Dictionary<string, string> MetricAvailability { get; init; } = [];
}
@@ -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);
@@ -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; }
}
@@ -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; }
}
@@ -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<string, string> 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<string, string> dimensions)
{
var segments = new List<string> { 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();
}
}
@@ -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);
}
}
@@ -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<RoutingEndpointDetails> ConvertToRoutingEndpointDetailsList(
RoutingEndpoints? endpoints)
{
if (endpoints is null)
{
return [];
}
var results = new List<RoutingEndpointDetails>();
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<RoutingEndpointDetails> results,
List<RoutingEndpointProperties>? 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;
}
}
@@ -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<string, JsonElement> _windowAggregates = new(StringComparer.Ordinal);
private readonly SortedDictionary<DateTimeOffset, Dictionary<string, JsonElement>> _buckets = [];
private readonly Dictionary<string, string> _fieldIdentities = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _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<string, string> 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<DateTimeOffset, long>();
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<string, string> 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<string, string> 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<string, JsonElement>(_windowAggregates, StringComparer.Ordinal),
[.. _buckets.Select(entry =>
{
var bucket = new Dictionary<string, JsonElement>(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<string, string>(_metricAvailability, StringComparer.Ordinal)
};
private string ResolveFieldName(
string metricName,
string aggregation,
string unit,
IReadOnlyDictionary<string, string> 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<string, JsonElement> GetBucket(DateTimeOffset timestamp)
{
var utcTimestamp = timestamp.ToUniversalTime();
if (!_buckets.TryGetValue(utcTimestamp, out var bucket))
{
bucket = new Dictionary<string, JsonElement>(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);
}
@@ -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;
}
@@ -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);
@@ -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<List<RoutingEndpointHealthSnapshot>> GetRoutingEndpointHealth(
string hubName,
string resourceGroup,
string subscription,
string? endpointName = null,
string? tenant = null,
CancellationToken cancellationToken = default);
Task<RoutingEndpointDiagnostics> 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);
}
File diff suppressed because it is too large. Load diff
@@ -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<IoTHubService> 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<IoTHubService> 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)
);
}
}
@@ -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);
}
@@ -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);
@@ -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);
@@ -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<string> DisabledDefaultSanitizers => [.. base.DisabledDefaultSanitizers, "AZSDK3493"];
public override List<GeneralRegexSanitizer> GeneralRegexSanitizers { get; } =
[
new(new()
{
Regex = @"(?i)(/subscriptions/|""subscriptionId""\s*:\s*"")(?<subscription>[0-9a-f-]{36})",
GroupForReplace = "subscription",
Value = "00000000-0000-0000-0000-000000000000"
}),
new(new()
{
Regex = @"(?i)(/resourceGroups/|resource group ')(?<group>[^/'""?]+)",
GroupForReplace = "group",
Value = "Sanitized"
})
];
// Routing evidence does not use subscription/resource tags or subscription display names.
public override List<BodyRegexSanitizer> BodyRegexSanitizers { get; } =
[
new(new()
{
Regex = @"""tags""\s*:\s*\{(?:[^""{}]|""(?:\\.|[^""\\])*"")*\}",
Value = "\"tags\": {}"
})
];
public override List<BodyKeySanitizer> BodyKeySanitizers { get; } =
[
new(new("$..displayName") { Value = "Sanitized" }),
new(new("$..resourcegroup") { Value = "Sanitized" })
];
public override List<HeaderRegexSanitizer> 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 _));
}
}
}
@@ -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)));
}
}
@@ -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<IoTHubRoutingEndpointDiagnosticsCommand, IIoTHubRoutingService>
{
[Fact]
public async Task ExecuteAsync_ReportsOperationTimeout()
{
Service.GetRoutingEndpointDiagnostics(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<string?>(), Arg.Any<DateTimeOffset?>(), Arg.Any<DateTimeOffset?>(),
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.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<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<DateTimeOffset?>(),
Arg.Any<DateTimeOffset?>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.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<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<DateTimeOffset?>(),
Arg.Any<DateTimeOffset?>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.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<string?>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteAsync_DeserializesEvidenceOnlyResult()
{
Service.GetRoutingEndpointDiagnostics(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<DateTimeOffset?>(),
Arg.Any<DateTimeOffset?>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.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<string, string> { ["Result"] = "Success" },
[(timestamp, 5d)]);
builder.AddAverageSeries(
"RoutingDeliveryLatency",
"Milliseconds",
new Dictionary<string, string>(),
[(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<string, string>
{
["SuccessfulRequests"] = "noValuesReturned"
}
})
]);
Service.GetRoutingEndpointDiagnostics(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<DateTimeOffset?>(),
Arg.Any<DateTimeOffset?>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.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<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<DateTimeOffset?>(),
Arg.Any<DateTimeOffset?>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.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"),
[]);
}
@@ -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<IoTHubRoutingEndpointHealthCommand, IIoTHubRoutingService>
{
[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<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.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<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.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<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.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<string?>(),
Arg.Any<CancellationToken>());
}
[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<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.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<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.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);
}
}
@@ -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<ArgumentException>(() =>
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);
}
}
@@ -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);
}
}
@@ -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<string, string>(), []);
builder.AddAverageSeries("EmptyAverage", "Milliseconds", new Dictionary<string, string>(), []);
builder.AddMaximumSeries("EmptyMaximum", "Percent", new Dictionary<string, string>(), []);
builder.MarkFailed("Failed");
builder.MarkSuccessful("Failed");
builder.AddCountSeries("Zero", "Total", new Dictionary<string, string>(), [(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<InvalidDataException>(() => builder.AddCountSeries(
"Count", "Total", new Dictionary<string, string>(), [(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<string, string>
{
["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<InvalidDataException>(() =>
builder.AddCountSeries(
"Transactions",
"Total",
new Dictionary<string, string> { ["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<string, string>(),
[(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<string, string>(),
[
(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<string, string> { ["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);
}
}
@@ -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<string?>(), Arg.Any<CancellationToken>())
.Returns(_ =>
{
cancellation.Cancel();
return Task.FromCanceled<TokenCredential>(cancellation.Token);
});
}
else
{
azureService.GetSubscription(Subscription, null, Arg.Any<CancellationToken>())
.Returns(_ =>
{
cancellation.Cancel();
return Task.FromCanceled<SubscriptionResource>(cancellation.Token);
});
}
await Assert.ThrowsAnyAsync<OperationCanceledException>(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<ArgumentException>(exception).ParamName);
await azureService.DidNotReceive().GetSubscription(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
await azureService.DidNotReceive().GetTokenCredentialAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>());
}
[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<OperationCanceledException>(() =>
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<OperationCanceledException>(() =>
DiagnoseAsync(CreateService(handler), cancellationToken: cancellation.Token));
Assert.Equal(2, TargetMetricRequests(handler).Count());
}
private static Task<RoutingEndpointDiagnostics> 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<Uri> 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<HttpRequestMessage, CancellationToken, HttpResponseMessage> metricResponse,
bool eventHub = false,
int endpointCount = 1,
Func<HttpRequestMessage, CancellationToken, HttpResponseMessage>? 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<IAzureCloudConfiguration>();
configuration.ArmEnvironment.Returns(ArmEnvironment.AzurePublicCloud);
azureService.CloudConfiguration.Returns(configuration);
var credential = Substitute.For<TokenCredential>();
credential.GetTokenAsync(Arg.Any<TokenRequestContext>(), Arg.Any<CancellationToken>())
.Returns(new AccessToken("fake-token", DateTimeOffset.UtcNow.AddHours(1)));
azureService.GetTokenCredentialAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(credential));
AzureServiceTestHelpers.ConfigureArmClientOptions(
azureService, new HttpClient(handler, disposeHandler: false), ArmEnvironment.AzurePublicCloud);
var subscription = Substitute.For<SubscriptionResource>();
subscription.Data.Returns(ResourceManagerModelFactory.SubscriptionData(subscriptionId: Subscription));
azureService.GetSubscription(Subscription, null, Arg.Any<CancellationToken>()).Returns(subscription);
var factory = Substitute.For<IHttpClientFactory>();
factory.CreateClient(Arg.Any<string>()).Returns(_ => new HttpClient(handler, disposeHandler: false));
return new IoTHubRoutingService(azureService, factory, Substitute.For<ILogger<IoTHubRoutingService>>());
}
}
@@ -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<RequestFailedException>(() =>
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<TimeoutException>(() =>
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<OperationCanceledException>(() =>
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<TokenCredential>();
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<CancellationToken>()).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<CancellationToken>());
await credential.Received().GetTokenAsync(
Arg.Is<TokenRequestContext>(context =>
context.Scopes.Length == 1 && context.Scopes[0] == ArmEnvironment.AzureGovernment.DefaultScope),
Arg.Any<CancellationToken>());
}
[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<SecurityException>(() =>
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<string?>(), Arg.Any<CancellationToken>());
}
[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<SecurityException>(() =>
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<ILogger<IoTHubRoutingService>>();
var cloudConfiguration = Substitute.For<IAzureCloudConfiguration>();
cloudConfiguration.ArmEnvironment.Returns(environment ?? ArmEnvironment.AzurePublicCloud);
azureService.CloudConfiguration.Returns(cloudConfiguration);
credential ??= Substitute.For<TokenCredential>();
credential.GetTokenAsync(Arg.Any<TokenRequestContext>(), Arg.Any<CancellationToken>())
.Returns(new AccessToken("fake-token", DateTimeOffset.UtcNow.AddHours(1)));
azureService.GetTokenCredentialAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(credential));
var factory = Substitute.For<IHttpClientFactory>();
factory.CreateClient(Arg.Any<string>()).Returns(_ => new HttpClient(handler, disposeHandler: false));
return new IoTHubRoutingService(azureService, factory, logger);
}
}
@@ -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<ArgumentException>(exception).ParamName);
await azureService.DidNotReceive().GetSubscription(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
await azureService.DidNotReceive().GetTokenCredentialAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>());
}
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<TokenCredential>();
credential.GetTokenAsync(Arg.Any<TokenRequestContext>(), Arg.Any<CancellationToken>())
.Returns(new AccessToken("fake-token", DateTimeOffset.UtcNow.AddHours(1)));
azureService.GetTokenCredentialAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(credential));
AzureServiceTestHelpers.ConfigureArmClientOptions(
azureService, new HttpClient(handler, disposeHandler: false), ArmEnvironment.AzurePublicCloud);
var subscription = Substitute.For<SubscriptionResource>();
subscription.Data.Returns(ResourceManagerModelFactory.SubscriptionData(subscriptionId: Subscription));
azureService.GetSubscription(Subscription, null, Arg.Any<CancellationToken>()).Returns(subscription);
return new IoTHubService(azureService, Substitute.For<ILogger<IoTHubService>>());
}
}
@@ -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<HttpRequestMessage, CancellationToken, HttpResponseMessage> respond) : HttpMessageHandler
{
public ConcurrentQueue<Uri> RequestUris { get; } = new();
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestUris.Enqueue(request.RequestUri!);
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(respond(request, cancellationToken));
}
}
@@ -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<HttpResponseMessage> _responses = new(responses);
public List<Uri?> RequestUris { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestUris.Add(request.RequestUri);
return Task.FromResult(_responses.Dequeue());
}
}
@@ -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"
}
@@ -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"
@@ -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