diff --git a/.vscode/cspell.json b/.vscode/cspell.json index a801e7bfd7..3f1732943d 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -368,6 +368,7 @@ "chilecentral", "cicd", "cloudarchitect", + "cloudhealth", "codegen", "codeium", "codeowners", diff --git a/Directory.Packages.props b/Directory.Packages.props index 6b08f9f8ce..a265d88a6a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,7 +11,7 @@ - + @@ -25,6 +25,7 @@ + @@ -94,7 +95,7 @@ - + diff --git a/eng/tools/ToolDescriptionEvaluator/prompts/prompts.json b/eng/tools/ToolDescriptionEvaluator/prompts/prompts.json index 8d68ca1c56..c415d871d2 100644 --- a/eng/tools/ToolDescriptionEvaluator/prompts/prompts.json +++ b/eng/tools/ToolDescriptionEvaluator/prompts/prompts.json @@ -548,8 +548,13 @@ "monitor_activitylog_list": [ "List the activity logs of the last month for " ], - "monitor_healthmodels_entity_get": [ - "Show me the health status of entity using the health model " + "monitor_healthmodels_get": [ + "Show me the health model in resource group ", + "Get the Azure Monitor health model named " + ], + "monitor_healthmodels_list": [ + "List the Azure Monitor health models in my subscription", + "What health models are in resource group ?" ], "monitor_metrics_definitions": [ "Get metric definitions for from the namespace", diff --git a/servers/Azure.Mcp.Server/KNOWN-ISSUES.md b/servers/Azure.Mcp.Server/KNOWN-ISSUES.md index 59d9002ba7..41a5e5f604 100644 --- a/servers/Azure.Mcp.Server/KNOWN-ISSUES.md +++ b/servers/Azure.Mcp.Server/KNOWN-ISSUES.md @@ -11,7 +11,6 @@ The following tools are not supported when the Azure MCP Server is running in HT | Namespace | Impacted Tools | |-----------|----------------| -| monitor | `azmcp_monitor_healthmodels_entity_gethealth` | | communication | `azmcp_communication_sms_send`, `azmcp_communication_email_send` | | confidentialledger | `azmcp_confidentialledger_entries_append`, `azmcp_confidentialledger_entries_get` | diff --git a/servers/Azure.Mcp.Server/README.md b/servers/Azure.Mcp.Server/README.md index a045da48aa..f7d3ad1efd 100644 --- a/servers/Azure.Mcp.Server/README.md +++ b/servers/Azure.Mcp.Server/README.md @@ -1116,6 +1116,8 @@ Example prompts that generate Azure CLI commands: ### 📊 Azure Monitor * "Query my Log Analytics workspace" +* "List my Azure Monitor Health Models" +* "Get details for my Azure Monitor Health Model 'my-health-model'" ### 🧭 Azure Monitor Instrumentation (under Azure Monitor) @@ -1255,7 +1257,7 @@ The Azure MCP Server provides tools for interacting with **44+ Azure service are - 🗃️ **Azure Managed Lustre** - High-performance Lustre filesystem operations - 🏪 **Azure Marketplace** - Product discovery - 🔄 **Azure Migrate** - Platform Landing Zone generation and modification guidance -- 📈 **Azure Monitor** - Logging, metrics, health monitoring, and instrumentation onboarding/migration workflow for local applications +- 📈 **Azure Monitor** - Logging, metrics, health models, health monitoring, and instrumentation onboarding/migration workflow for local applications - ⚖️ **Azure Policy** - Policies set to enforce organizational standards - ⚙️ **Azure Native ISV Services** - Third-party integrations - 🛡️ **Azure Quick Review CLI** - Compliance scanning diff --git a/servers/Azure.Mcp.Server/changelog-entries/1782800143580.yaml b/servers/Azure.Mcp.Server/changelog-entries/1782800143580.yaml new file mode 100644 index 0000000000..1887cf5d72 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/1782800143580.yaml @@ -0,0 +1,3 @@ +changes: + - section: "Features Added" + description: "Replaced the broken 'monitor healthmodels entity get' command with 'monitor healthmodels list' and 'monitor healthmodels get', using the Azure Health Models 2026-05-01-preview API (Microsoft.CloudHealth) via the Azure.ResourceManager.CloudHealth SDK." diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index cee0a848b5..0bad39ca90 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3065,12 +3065,16 @@ azmcp monitor workspace log query --subscription \ #### Health Models ```bash -# Get the health of an entity +# List health models in a subscription (optionally scoped to a resource group) # ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired -azmcp monitor healthmodels entity get --subscription \ - --resource-group \ - --health-model \ - --entity +azmcp monitor healthmodels list --subscription \ + [--resource-group ] + +# Get (show) a single health model by name +# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp monitor healthmodels get --subscription \ + --resource-group \ + --health-model ``` #### Metrics diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index 0c61109223..6b5527188d 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -768,7 +768,9 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | Tool Name | Test Prompt | |:----------|:----------| | monitor_activitylog_list | List the activity logs of the last month for | -| monitor_healthmodels_entity_get | Show me the health status of entity using the health model | +| monitor_healthmodels_get | Show me the health model in resource group | +| monitor_healthmodels_list | List the Azure Monitor health models in my subscription | +| monitor_healthmodels_list | What health models are in resource group ? | | monitor_instrumentation_get-learning-resource | Get the onboarding learning resource at path | | monitor_instrumentation_get-learning-resource | Show me the content of the Azure Monitor onboarding learning resource at path | | monitor_instrumentation_get-learning-resource | Get the content of the Azure Monitor learning resource file at path | diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index ae8c622d34..d459804e74 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -400,7 +400,8 @@ "datadog_monitoredresources_list", "monitor_workspace_list", "monitor_activitylog_list", - "monitor_healthmodels_entity_get", + "monitor_healthmodels_list", + "monitor_healthmodels_get", "monitor_metrics_definitions", "monitor_metrics_query", "monitor_resource_log_query", diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Azure.Mcp.Tools.Monitor.csproj b/tools/Azure.Mcp.Tools.Monitor/src/Azure.Mcp.Tools.Monitor.csproj index 6e11d39d8c..3a03b666a8 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/Azure.Mcp.Tools.Monitor.csproj +++ b/tools/Azure.Mcp.Tools.Monitor/src/Azure.Mcp.Tools.Monitor.csproj @@ -27,6 +27,7 @@ + diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Commands/HealthModels/Entity/EntityGetHealthCommand.cs b/tools/Azure.Mcp.Tools.Monitor/src/Commands/HealthModels/Entity/EntityGetHealthCommand.cs deleted file mode 100644 index f297cba1c6..0000000000 --- a/tools/Azure.Mcp.Tools.Monitor/src/Commands/HealthModels/Entity/EntityGetHealthCommand.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json.Nodes; -using Azure.Mcp.Core.Commands.Subscription; -using Azure.Mcp.Core.Services.Azure.Subscription; -using Azure.Mcp.Tools.Monitor.Options; -using Azure.Mcp.Tools.Monitor.Services; -using Microsoft.Extensions.Logging; -using Microsoft.Mcp.Core.Commands; -using Microsoft.Mcp.Core.Models.Command; - -namespace Azure.Mcp.Tools.Monitor.Commands.HealthModels.Entity; - -[CommandMetadata( - Id = "80b23546-a6ac-4f0c-ad70-f51d6dff5543", - Name = "get", - Title = "Get the health of an entity in a health model", - Description = """ - Retrieve the health status of an entity for a given Azure Monitor Health Model. Use this tool ONLY when the user mentions a specific health model name and asks for health status, health events. This provides application-level health monitoring with custom health models, not basic Azure resource availability. - For basic Azure resource availability status, use Resource Health tool instead `azmcp_resourcehealth_availability-status_get`. - For querying logs from a Log Analystics workspace, use `azmcp_monitor_workspace_log_query`. - For querying logs of a specific Azure resource, use `azmcp_monitor_resource_log_query`. - """, - Destructive = false, - Idempotent = true, - OpenWorld = false, - ReadOnly = true, - Secret = false, - LocalRequired = false)] -public sealed class EntityGetHealthCommand(ILogger logger, IMonitorHealthModelService healthModelService, ISubscriptionResolver subscriptionResolver) - : SubscriptionCommand(subscriptionResolver) -{ - private readonly ILogger _logger = logger; - private readonly IMonitorHealthModelService _healthModelService = healthModelService; - - public override async Task ExecuteAsync(CommandContext context, BaseMonitorHealthModelsOptions options, CancellationToken cancellationToken) - { - try - { - var result = await _healthModelService.GetEntityHealth( - options.Entity, - options.HealthModel, - options.ResourceGroup, - options.Subscription!, - options.AuthMethod, - options.Tenant, - options.RetryPolicy, - cancellationToken); - - context.Response.Results = ResponseResult.Create(result, MonitorJsonContext.Default.JsonNode); - } - catch (Exception ex) - { - _logger.LogError(ex, - "An exception occurred getting health for entity: {Entity} in healthModel: {HealthModelName}, resourceGroup: {ResourceGroup}, subscription: {Subscription}, authMethod: {AuthMethod}" - + ", tenant: {Tenant}.", - options.Entity, - options.HealthModel, - options.ResourceGroup, - options.Subscription, - options.AuthMethod, - options.Tenant); - HandleException(context, ex); - } - - return context.Response; - } - - protected override string GetErrorMessage(Exception ex) => ex switch - { - KeyNotFoundException => $"Entity or health model not found. Please check the entity ID, health model name, and resource group.", - ArgumentException argEx => $"Invalid argument: {argEx.Message}", - _ => base.GetErrorMessage(ex) - }; -} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Commands/HealthModels/HealthModelGetCommand.cs b/tools/Azure.Mcp.Tools.Monitor/src/Commands/HealthModels/HealthModelGetCommand.cs new file mode 100644 index 0000000000..1fdb3d0d26 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Commands/HealthModels/HealthModelGetCommand.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Tools.Monitor.Models.HealthModels; +using Azure.Mcp.Tools.Monitor.Options.HealthModels; +using Azure.Mcp.Tools.Monitor.Services; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; + +namespace Azure.Mcp.Tools.Monitor.Commands.HealthModels; + +[CommandMetadata( + Id = "1e5a6b2c-0d4e-4f8a-9b3c-7a2d8e1f6b0d", + Name = "get", + Title = "Get an Azure Monitor Health Model", + Description = """ + Get (show/view) a single, specific Azure Monitor Health Model by name, within a resource group and subscription. + Returns the model's metadata and if available, the health state (e.g. Healthy, Degraded, Unhealthy, Unknown) or null if it cannot be provided. + """, + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + Secret = false, + LocalRequired = false)] +public sealed class HealthModelGetCommand(IMonitorHealthModelService healthModelService, ISubscriptionResolver subscriptionResolver) + : SubscriptionCommand(subscriptionResolver) +{ + private readonly IMonitorHealthModelService _healthModelService = healthModelService; + + public override async Task ExecuteAsync(CommandContext context, HealthModelGetOptions options, CancellationToken cancellationToken) + { + try + { + var model = await _healthModelService.GetHealthModel( + options.Subscription!, + options.ResourceGroup, + options.HealthModel, + options.Tenant, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create(new(model), MonitorJsonContext.Default.HealthModelGetCommandResult); + } + catch (Exception ex) + { + HandleException(context, ex); + } + + return context.Response; + } + + public sealed record HealthModelGetCommandResult(HealthModelDetail HealthModel); +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Commands/HealthModels/HealthModelListCommand.cs b/tools/Azure.Mcp.Tools.Monitor/src/Commands/HealthModels/HealthModelListCommand.cs new file mode 100644 index 0000000000..dde64be399 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Commands/HealthModels/HealthModelListCommand.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Tools.Monitor.Models.HealthModels; +using Azure.Mcp.Tools.Monitor.Options.HealthModels; +using Azure.Mcp.Tools.Monitor.Services; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; + +namespace Azure.Mcp.Tools.Monitor.Commands.HealthModels; + +[CommandMetadata( + Id = "9d9d2f3a-6f9d-4a3a-8d7f-2c0b6a1f5e4c", + Name = "list", + Title = "List Azure Monitor Health Models", + Description = """ + List (find/show/enumerate) Azure Monitor Health Models (Microsoft.CloudHealth/healthmodels) in a subscription, or scoped to a specific resource group. + Returns a summary of each health model, including its name, resource group, and location. + """, + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + Secret = false, + LocalRequired = false)] +public sealed class HealthModelListCommand(IMonitorHealthModelService healthModelService, ISubscriptionResolver subscriptionResolver) + : SubscriptionCommand>(subscriptionResolver) +{ + private readonly IMonitorHealthModelService _healthModelService = healthModelService; + + public override async Task ExecuteAsync(CommandContext context, HealthModelListOptions options, CancellationToken cancellationToken) + { + try + { + var models = await _healthModelService.ListHealthModels( + options.Subscription!, + options.ResourceGroup, + options.Tenant, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create(models, MonitorJsonContext.Default.ListHealthModelSummary); + } + catch (Exception ex) + { + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Commands/MonitorJsonContext.cs b/tools/Azure.Mcp.Tools.Monitor/src/Commands/MonitorJsonContext.cs index 14d1a8fec4..3f0e59a315 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/Commands/MonitorJsonContext.cs +++ b/tools/Azure.Mcp.Tools.Monitor/src/Commands/MonitorJsonContext.cs @@ -5,6 +5,7 @@ using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Azure.Mcp.Tools.Monitor.Commands.ActivityLog; +using Azure.Mcp.Tools.Monitor.Commands.HealthModels; using Azure.Mcp.Tools.Monitor.Commands.Instrumentation; using Azure.Mcp.Tools.Monitor.Commands.Metrics; using Azure.Mcp.Tools.Monitor.Commands.Table; @@ -12,6 +13,7 @@ using Azure.Mcp.Tools.Monitor.Commands.WebTests; using Azure.Mcp.Tools.Monitor.Commands.Workspace; using Azure.Mcp.Tools.Monitor.Models.ActivityLog; +using Azure.Mcp.Tools.Monitor.Models.HealthModels; namespace Azure.Mcp.Tools.Monitor.Commands; @@ -21,6 +23,11 @@ namespace Azure.Mcp.Tools.Monitor.Commands; [JsonSerializable(typeof(ActivityLogListResponse))] [JsonSerializable(typeof(ActivityLogLocalizableString))] [JsonSerializable(typeof(GetLearningResourceCommand.GetLearningResourceCommandResult))] +[JsonSerializable(typeof(HealthModelDetail))] +[JsonSerializable(typeof(HealthModelGetCommand.HealthModelGetCommandResult))] +[JsonSerializable(typeof(HealthModelIdentity))] +[JsonSerializable(typeof(HealthModelSummary))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(MetricsDefinitionsCommand.MetricsDefinitionsCommandResult))] [JsonSerializable(typeof(MetricsDefinitionsCommand.MetricsDefinitionsCommandResult))] diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Models/HealthModels/HealthModelDetail.cs b/tools/Azure.Mcp.Tools.Monitor/src/Models/HealthModels/HealthModelDetail.cs new file mode 100644 index 0000000000..87eaf52dfc --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Models/HealthModels/HealthModelDetail.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.Monitor.Models.HealthModels; + +/// +/// Azure Monitor Health Model with its identity and tags and a health state. +/// is null when the root entity's health cannot be retrieved. +/// +public sealed class HealthModelDetail : HealthModelSummary +{ + [JsonPropertyName("healthState")] + public string? HealthState { get; set; } + + [JsonPropertyName("identity")] + public HealthModelIdentity? Identity { get; set; } + + [JsonPropertyName("tags")] + public IDictionary? Tags { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Models/HealthModels/HealthModelIdentity.cs b/tools/Azure.Mcp.Tools.Monitor/src/Models/HealthModels/HealthModelIdentity.cs new file mode 100644 index 0000000000..0fdeffc370 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Models/HealthModels/HealthModelIdentity.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.Monitor.Models.HealthModels; + +/// +/// Managed identity configuration of an Azure Monitor Health Model. +/// +public sealed class HealthModelIdentity +{ + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("principalId")] + public string? PrincipalId { get; set; } + + [JsonPropertyName("tenantId")] + public string? TenantId { get; set; } + + [JsonPropertyName("userAssignedIdentities")] + public IReadOnlyList? UserAssignedIdentities { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Models/HealthModels/HealthModelSummary.cs b/tools/Azure.Mcp.Tools.Monitor/src/Models/HealthModels/HealthModelSummary.cs new file mode 100644 index 0000000000..3a9e8e0946 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Models/HealthModels/HealthModelSummary.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.Monitor.Models.HealthModels; + +/// +/// Reduced projection of an Azure Monitor Health Model. +/// +public class HealthModelSummary +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("resourceGroup")] + public string? ResourceGroup { get; set; } + + [JsonPropertyName("location")] + public string? Location { get; set; } + + [JsonPropertyName("provisioningState")] + public string? ProvisioningState { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs b/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs index 2e434e4d98..732c4d6c1f 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs +++ b/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs @@ -2,7 +2,7 @@ // Licensed under the MIT License. using Azure.Mcp.Tools.Monitor.Commands.ActivityLog; -using Azure.Mcp.Tools.Monitor.Commands.HealthModels.Entity; +using Azure.Mcp.Tools.Monitor.Commands.HealthModels; using Azure.Mcp.Tools.Monitor.Commands.Instrumentation; using Azure.Mcp.Tools.Monitor.Commands.Log; using Azure.Mcp.Tools.Monitor.Commands.Metrics; @@ -78,7 +78,8 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -139,13 +140,11 @@ public CommandGroup RegisterCommands(IServiceProvider serviceProvider) monitorTableType.AddCommand(serviceProvider); - var health = new CommandGroup("healthmodels", "Azure Monitor Health Models operations - Commands for working with Azure Monitor Health Models."); + var health = new CommandGroup("healthmodels", "Azure Monitor Health Models operations - Commands for listing and retrieving Azure Monitor Health Models (Microsoft.CloudHealth/healthmodels)."); monitor.AddSubGroup(health); - var entity = new CommandGroup("entity", "Entity operations - Commands for working with entities in Azure Monitor Health Models."); - health.AddSubGroup(entity); - - entity.AddCommand(serviceProvider); + health.AddCommand(serviceProvider); + health.AddCommand(serviceProvider); // Create Metrics command group and register commands var metrics = new CommandGroup("metrics", "Azure Monitor metrics operations - Commands for querying and analyzing Azure Monitor metrics."); diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Options/BaseMonitorHealthModelsOptions.cs b/tools/Azure.Mcp.Tools.Monitor/src/Options/HealthModels/HealthModelGetOptions.cs similarity index 57% rename from tools/Azure.Mcp.Tools.Monitor/src/Options/BaseMonitorHealthModelsOptions.cs rename to tools/Azure.Mcp.Tools.Monitor/src/Options/HealthModels/HealthModelGetOptions.cs index d89ff5c86d..5603a4783e 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/Options/BaseMonitorHealthModelsOptions.cs +++ b/tools/Azure.Mcp.Tools.Monitor/src/Options/HealthModels/HealthModelGetOptions.cs @@ -2,17 +2,13 @@ // Licensed under the MIT License. using Azure.Mcp.Core.Options; -using Microsoft.Mcp.Core.Models; using Microsoft.Mcp.Core.Options; -namespace Azure.Mcp.Tools.Monitor.Options; +namespace Azure.Mcp.Tools.Monitor.Options.HealthModels; -public sealed class BaseMonitorHealthModelsOptions : ISubscriptionOption +public sealed class HealthModelGetOptions : ISubscriptionOption { - [Option(Description = "The entity to get health for.")] - public required string Entity { get; set; } - - [Option(Description = "The name of the health model for which to get the health.")] + [Option(Description = "The name of the health model to retrieve.")] public required string HealthModel { get; set; } [Option(Description = OptionDescriptions.Tenant)] @@ -26,7 +22,4 @@ public sealed class BaseMonitorHealthModelsOptions : ISubscriptionOption [OptionContainer(Prefix = "retry")] public RetryPolicyOptions? RetryPolicy { get; set; } - - [Option(Description = OptionDescriptions.AuthMethod)] - public AuthMethod? AuthMethod { get; set; } } diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Options/HealthModels/HealthModelListOptions.cs b/tools/Azure.Mcp.Tools.Monitor/src/Options/HealthModels/HealthModelListOptions.cs new file mode 100644 index 0000000000..8c51a2b1c1 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Options/HealthModels/HealthModelListOptions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.Monitor.Options.HealthModels; + +public sealed class HealthModelListOptions : ISubscriptionOption +{ + [Option(Description = OptionDescriptions.Tenant)] + public string? Tenant { get; set; } + + [Option(Description = OptionDescriptions.Subscription)] + public string? Subscription { get; set; } + + [Option(Description = OptionDescriptions.ResourceGroup)] + public string? ResourceGroup { get; set; } + + [OptionContainer(Prefix = "retry")] + public RetryPolicyOptions? RetryPolicy { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Services/IMonitorHealthModelService.cs b/tools/Azure.Mcp.Tools.Monitor/src/Services/IMonitorHealthModelService.cs index 46ff9baa0b..379ef9e462 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/Services/IMonitorHealthModelService.cs +++ b/tools/Azure.Mcp.Tools.Monitor/src/Services/IMonitorHealthModelService.cs @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json.Nodes; -using Microsoft.Mcp.Core.Models; +using Azure.Mcp.Tools.Monitor.Models.HealthModels; using Microsoft.Mcp.Core.Options; namespace Azure.Mcp.Tools.Monitor.Services; @@ -10,26 +9,36 @@ namespace Azure.Mcp.Tools.Monitor.Services; public interface IMonitorHealthModelService { /// - /// Gets the health of an entity from a health model + /// Lists Azure Monitor Health Models in a subscription or resource group. Returns a summary projection. /// - /// The entity to get health for - /// The health model name - /// The resource group name containing the health model - /// Subscription ID or name - /// Optional authentication method - /// Optional tenant ID for cross-tenant operations - /// Optional retry policy for the operation - /// A cancellation token - /// Entity health information - /// When authentication fails - /// When the service request fails - Task GetEntityHealth( - string entity, - string healthModelName, - string resourceGroup, + /// Subscription ID or name. + /// Optional resource group to scope the listing. + /// Optional tenant ID. + /// Optional retry policy. + /// A cancellation token. + /// List health models. + Task> ListHealthModels( + string subscription, + string? resourceGroup = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); + + /// + /// Gets a single Azure Monitor Health Model by name. + /// + /// Subscription ID or name. + /// The resource group containing the health model. + /// The health model name. + /// Optional tenant ID. + /// Optional retry policy. + /// A cancellation token. + /// The health model resource. + Task GetHealthModel( string subscription, - AuthMethod? authMethod = null, - string? tenantId = null, + string resourceGroup, + string healthModelName, + string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); } diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorHealthModelService.cs b/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorHealthModelService.cs index 28ca9db20f..57f2c1984a 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorHealthModelService.cs +++ b/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorHealthModelService.cs @@ -1,139 +1,130 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json.Nodes; -using Azure.Core; +using System.Linq; +using Azure; using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Core.Services.Azure.Subscription; using Azure.Mcp.Core.Services.Azure.Tenant; -using Microsoft.Mcp.Core.Models; +using Azure.Mcp.Tools.Monitor.Models.HealthModels; +using Azure.ResourceManager.CloudHealth; +using Azure.ResourceManager.Models; +using Microsoft.Extensions.Logging; using Microsoft.Mcp.Core.Options; -using Microsoft.Mcp.Core.Services.Azure.Authentication; namespace Azure.Mcp.Tools.Monitor.Services; -public class MonitorHealthModelService(ITenantService tenantService, IHttpClientFactory httpClientFactory) +public class MonitorHealthModelService(ISubscriptionService subscriptionService, ITenantService tenantService, ILogger logger) : BaseAzureService(tenantService), IMonitorHealthModelService { - private const string ApiVersion = "2023-10-01-preview"; - private readonly IHttpClientFactory _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); - private readonly ITenantService _tenantService = tenantService ?? throw new ArgumentNullException(nameof(tenantService)); + private readonly ISubscriptionService _subscriptionService = subscriptionService ?? throw new ArgumentNullException(nameof(subscriptionService)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - /// - /// Retrieves the health information for a specific entity in a health model. - /// - /// The identifier of the entity whose health is being queried. - /// The name of the health model to query. - /// The name of the resource group containing the health model. - /// The Azure subscription ID containing the resource group. - /// Optional. The authentication method to use for the request. - /// Optional. The Azure tenant ID for authentication. - /// Optional. Policy parameters for retrying failed requests. - /// A cancellation token. - /// A JSON node containing the entity's health information. - /// Thrown when required parameters are missing or invalid. - /// Thrown when parsing the health response fails. - public async Task GetEntityHealth( - string entity, - string healthModelName, - string resourceGroupName, + internal static HealthModelSummary ToSummary(HealthModelData data) => + new() + { + Id = data.Id?.ToString(), + Name = data.Name, + ResourceGroup = data.Id?.ResourceGroupName, + Location = data.Location.ToString(), + ProvisioningState = data.HealthModelProvisioningState?.ToString(), + }; + + public async Task> ListHealthModels( string subscription, - AuthMethod? authMethod = null, - string? tenantId = null, + string? resourceGroup = null, + string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { - ValidateRequiredParameters((nameof(entity), entity), (nameof(healthModelName), healthModelName), (nameof(resourceGroupName), resourceGroupName), (nameof(subscription), subscription)); - - string dataplaneEndpoint = await GetDataplaneEndpointAsync(subscription, resourceGroupName, healthModelName, cancellationToken); - string entityHealthUrl = $"{dataplaneEndpoint}api/entities/{entity}/history"; - - string healthResponseString = await GetDataplaneResponseAsync(entityHealthUrl, cancellationToken); - return JsonNode.Parse(healthResponseString) ?? throw new Exception("Failed to parse health response to JSON."); - } - - private async Task GetDataplaneResponseAsync(string url, CancellationToken cancellationToken) - { - string dataplaneToken = await GetDataplaneTokenAsync(cancellationToken); - using var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", dataplaneToken); - - var client = _httpClientFactory.CreateClient(); - HttpResponseMessage healthResponse = await client.SendAsync(request, cancellationToken); - healthResponse.EnsureSuccessStatusCode(); - - string healthResponseString = await healthResponse.Content.ReadAsStringAsync(cancellationToken); - return healthResponseString; - } - - private async Task GetDataplaneEndpointAsync(string subscriptionId, string resourceGroupName, string healthModelName, CancellationToken cancellationToken) - { - string token = await GetControlPlaneTokenAsync(cancellationToken); - string healthModelUrl = $"{GetManagementEndpoint()}/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.CloudHealth/healthmodels/{healthModelName}?api-version={ApiVersion}"; - - using var request = new HttpRequestMessage(HttpMethod.Get, healthModelUrl); - request.Headers.Authorization = new("Bearer", token); + ValidateRequiredParameters((nameof(subscription), subscription)); - var client = _httpClientFactory.CreateClient(); - HttpResponseMessage response = await client.SendAsync(request, cancellationToken); - response.EnsureSuccessStatusCode(); - string responseString = await response.Content.ReadAsStringAsync(cancellationToken); + var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy, cancellationToken); + var results = new List(); - string dataplaneEndpoint = GetDataplaneEndpoint(responseString); - return dataplaneEndpoint; - } - - private static string GetDataplaneEndpoint(string jsonResponse) - { - try + if (string.IsNullOrEmpty(resourceGroup)) { - JsonNode? json = JsonNode.Parse(jsonResponse); - string? dataplaneEndpoint = json?["properties"]?["dataplaneEndpoint"]?.GetValue(); - if (string.IsNullOrEmpty(dataplaneEndpoint)) + await foreach (var model in subscriptionResource.GetHealthModelsAsync(cancellationToken)) { - throw new Exception("Dataplane endpoint is null or empty in the response."); + results.Add(ToSummary(model.Data)); } - - return dataplaneEndpoint!; } - catch (Exception ex) + else { - string errorMessage = $"Error parsing dataplane endpoint: {ex.Message}"; - throw new Exception(errorMessage, ex); + var resourceGroupResource = await subscriptionResource.GetResourceGroupAsync(resourceGroup, cancellationToken); + await foreach (var model in resourceGroupResource.Value.GetHealthModels().GetAllAsync(cancellationToken: cancellationToken)) + { + results.Add(ToSummary(model.Data)); + } } - } - private async Task GetControlPlaneTokenAsync(CancellationToken cancellationToken) - { - return (await GetArmAccessTokenAsync(null, cancellationToken)).Token; + return results; } - private async Task GetDataplaneTokenAsync(CancellationToken cancellationToken) + internal static HealthModelDetail ToDetail(HealthModelData data, string? healthState) => + new() + { + Id = data.Id?.ToString(), + Name = data.Name, + ResourceGroup = data.Id?.ResourceGroupName, + Location = data.Location.ToString(), + ProvisioningState = data.HealthModelProvisioningState?.ToString(), + HealthState = healthState, + Identity = ToIdentity(data.Identity), + Tags = data.Tags, + }; + + internal static HealthModelIdentity? ToIdentity(ManagedServiceIdentity? identity) { - TokenCredential credential = await GetCredential(cancellationToken); - AccessToken accessToken = await credential.GetTokenAsync( - new([GetHealthModelsDataApiScope()]), - cancellationToken); + if (identity is null) + { + return null; + } - return accessToken.Token; + return new HealthModelIdentity + { + Type = identity.ManagedServiceIdentityType.ToString(), + PrincipalId = identity.PrincipalId?.ToString(), + TenantId = identity.TenantId?.ToString(), + UserAssignedIdentities = identity.UserAssignedIdentities? + .Keys.Select(id => id.ToString()).ToList(), + }; } - private string GetManagementEndpoint() + public async Task GetHealthModel( + string subscription, + string resourceGroup, + string healthModelName, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) { - return _tenantService.CloudConfiguration.ArmEnvironment.Endpoint.ToString().TrimEnd('/'); + ValidateRequiredParameters( + (nameof(subscription), subscription), + (nameof(resourceGroup), resourceGroup), + (nameof(healthModelName), healthModelName)); + + var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy, cancellationToken); + var resourceGroupResource = await subscriptionResource.GetResourceGroupAsync(resourceGroup, cancellationToken); + var model = await resourceGroupResource.Value.GetHealthModels().GetAsync(healthModelName, cancellationToken); + var healthState = await TryGetRootHealthStateAsync(model.Value, healthModelName, cancellationToken); + return ToDetail(model.Value.Data, healthState); } - private string GetHealthModelsDataApiScope() + private async Task TryGetRootHealthStateAsync(HealthModelResource model, string rootEntityName, CancellationToken cancellationToken) { - return _tenantService.CloudConfiguration.CloudType switch + try { - AzureCloudConfiguration.AzureCloud.AzurePublicCloud => - "https://data.healthmodels.azure.com/.default", - AzureCloudConfiguration.AzureCloud.AzureChinaCloud => - "https://data.healthmodels.azure.cn/.default", - AzureCloudConfiguration.AzureCloud.AzureUSGovernmentCloud => - "https://data.healthmodels.azure.us/.default", - _ => - "https://data.healthmodels.azure.com/.default" - }; + var entity = await model.GetHealthModelEntityAsync(rootEntityName, cancellationToken); + return entity.Value.Data.Properties?.HealthState?.ToString(); + } + catch (RequestFailedException ex) + { + _logger.LogWarning(ex, + "Could not resolve root-entity health for health model '{HealthModel}'; returning null healthState. Error: {Message}", + rootEntityName, + ex.Message); + return null; + } } } diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/Entity/EntityGetHealthCommandTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/Entity/EntityGetHealthCommandTests.cs deleted file mode 100644 index b96e299a24..0000000000 --- a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/Entity/EntityGetHealthCommandTests.cs +++ /dev/null @@ -1,291 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Net; -using System.Text.Json.Nodes; -using Azure.Mcp.Tests.Commands; -using Azure.Mcp.Tools.Monitor.Commands.HealthModels.Entity; -using Azure.Mcp.Tools.Monitor.Services; -using Microsoft.Mcp.Core.Models; -using Microsoft.Mcp.Core.Options; -using NSubstitute; -using NSubstitute.ExceptionExtensions; -using Xunit; - -namespace Azure.Mcp.Tools.Monitor.Tests.HealthModels.Entity; - -public class EntityGetHealthCommandTests : SubscriptionCommandUnitTestsBase -{ - // Sample test data - private const string TestEntity = "entity123"; - private const string TestHealthModel = "healthModel1"; - private const string TestResourceGroup = "resourceGroup1"; - private const string TestSubscription = "sub123"; - private const string TestTenant = "tenant123"; - - [Fact] - public async Task ExecuteAsync_WithValidParameters_ReturnsEntityHealth() - { - // Arrange - JsonNode mockResponse = new JsonObject([new("entityId", "entity123"), new("health", "Healthy"), new("timestamp", "2023-05-01T12:00:00Z")]); - - Service.GetEntityHealth( - TestEntity, - TestHealthModel, - TestResourceGroup, - TestSubscription, - Arg.Any(), - TestTenant, - Arg.Any(), - Arg.Any()) - .Returns(mockResponse); - - // Act - var result = await ExecuteCommandAsync( - "--entity", TestEntity, - "--health-model", TestHealthModel, - "--resource-group", TestResourceGroup, - "--subscription", TestSubscription, - "--tenant", TestTenant); - - // Assert - Assert.NotNull(result); - Assert.Equal(HttpStatusCode.OK, result.Status); - Assert.NotNull(result.Results); - - await Service.Received(1).GetEntityHealth( - TestEntity, - TestHealthModel, - TestResourceGroup, - TestSubscription, - Arg.Any(), - TestTenant, - Arg.Any(), - Arg.Any()); - } - - [Fact] - public async Task ExecuteAsync_WithMissingRequiredParameters_ReturnsBadRequest() - { - // Arrange & Act - missing entity parameter - var result = await ExecuteCommandAsync( - "--health-model", TestHealthModel, - "--resource-group", TestResourceGroup, - "--subscription", TestSubscription); - - // Assert - Assert.NotNull(result); - Assert.Equal(HttpStatusCode.BadRequest, result.Status); - Assert.Contains("required", result.Message, StringComparison.OrdinalIgnoreCase); - - // Verify service was not called - await Service.DidNotReceive().GetEntityHealth( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); - } - - [Fact] - public async Task ExecuteAsync_EntityNotFound_ReturnsNotFound() - { - // Arrange - Service.GetEntityHealth( - TestEntity, - TestHealthModel, - TestResourceGroup, - TestSubscription, - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()) - .ThrowsAsync(new KeyNotFoundException("Entity not found")); - - // Act - var result = await ExecuteCommandAsync( - "--entity", TestEntity, - "--health-model", TestHealthModel, - "--resource-group", TestResourceGroup, - "--subscription", TestSubscription); - - // Assert - Assert.NotNull(result); - Assert.Equal(HttpStatusCode.NotFound, result.Status); - Assert.Contains("not found", result.Message, StringComparison.OrdinalIgnoreCase); - - await Service.Received(1).GetEntityHealth( - TestEntity, - TestHealthModel, - TestResourceGroup, - TestSubscription, - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); - } - - [Fact] - public async Task ExecuteAsync_InvalidArgument_ReturnsBadRequest() - { - // Arrange - Service.GetEntityHealth( - TestEntity, - TestHealthModel, - TestResourceGroup, - TestSubscription, - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()) - .ThrowsAsync(new ArgumentException("Invalid health model format")); - - // Act - var result = await ExecuteCommandAsync( - "--entity", TestEntity, - "--health-model", TestHealthModel, - "--resource-group", TestResourceGroup, - "--subscription", TestSubscription); - - // Assert - Assert.NotNull(result); - Assert.Equal(HttpStatusCode.BadRequest, result.Status); - Assert.Contains("Invalid argument", result.Message); - - await Service.Received(1).GetEntityHealth( - TestEntity, - TestHealthModel, - TestResourceGroup, - TestSubscription, - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); - } - - [Fact] - public async Task ExecuteAsync_GeneralException_ReturnsInternalServerError() - { - // Arrange - var expectedError = "Unexpected error occurred"; - Service.GetEntityHealth( - TestEntity, - TestHealthModel, - TestResourceGroup, - TestSubscription, - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()) - .ThrowsAsync(new Exception(expectedError)); - - // Act - var result = await ExecuteCommandAsync( - "--entity", TestEntity, - "--health-model", TestHealthModel, - "--resource-group", TestResourceGroup, - "--subscription", TestSubscription); - - // Assert - Assert.NotNull(result); - Assert.Equal(HttpStatusCode.InternalServerError, result.Status); - Assert.Contains(expectedError, result.Message); - - await Service.Received(1).GetEntityHealth( - TestEntity, - TestHealthModel, - TestResourceGroup, - TestSubscription, - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); - } - - [Fact] - public async Task ExecuteAsync_WithAuthMethod_PassesAuthMethodToService() - { - // Arrange - var mockResponse = new JsonObject([new("entityId", "entity123"), new("health", "Healthy")]); - var authMethod = AuthMethod.Credential.ToString().ToLowerInvariant(); - - Service.GetEntityHealth( - TestEntity, - TestHealthModel, - TestResourceGroup, - TestSubscription, - Arg.Is(AuthMethod.Credential), - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(mockResponse); - - // Act - var result = await ExecuteCommandAsync( - "--entity", TestEntity, - "--health-model", TestHealthModel, - "--resource-group", TestResourceGroup, - "--subscription", TestSubscription, - "--auth-method", authMethod); - - // Assert - Assert.NotNull(result); - Assert.Equal(HttpStatusCode.OK, result.Status); - - await Service.Received(1).GetEntityHealth( - TestEntity, - TestHealthModel, - TestResourceGroup, - TestSubscription, - Arg.Is(AuthMethod.Credential), - Arg.Any(), - Arg.Any(), - Arg.Any()); - } - - [Fact] - public async Task ExecuteAsync_WithRetryPolicy_PassesRetryPolicyToService() - { - // Arrange - var mockResponse = new JsonObject([new("entityId", "entity123"), new("health", "Healthy")]); - const double RetryDelay = 3; - const int MaxRetries = 5; - - Service.GetEntityHealth( - TestEntity, - TestHealthModel, - TestResourceGroup, - TestSubscription, - Arg.Any(), - Arg.Any(), - Arg.Is(r => r.DelaySeconds == RetryDelay && r.MaxRetries == MaxRetries), - Arg.Any()) - .Returns(mockResponse); - - // Act - var result = await ExecuteCommandAsync( - "--entity", TestEntity, - "--health-model", TestHealthModel, - "--resource-group", TestResourceGroup, - "--subscription", TestSubscription, - "--retry-delay", RetryDelay.ToString(), - "--retry-max-retries", MaxRetries.ToString()); - - // Assert - Assert.NotNull(result); - Assert.Equal(HttpStatusCode.OK, result.Status); - - await Service.Received(1).GetEntityHealth( - TestEntity, - TestHealthModel, - TestResourceGroup, - TestSubscription, - Arg.Any(), - Arg.Any(), - Arg.Is(r => r.DelaySeconds == RetryDelay && r.MaxRetries == MaxRetries), - Arg.Any()); - } -} diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/HealthModelGetCommandTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/HealthModelGetCommandTests.cs new file mode 100644 index 0000000000..7a291dbb4c --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/HealthModelGetCommandTests.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tests.Commands; +using Azure.Mcp.Tools.Monitor.Commands; +using Azure.Mcp.Tools.Monitor.Commands.HealthModels; +using Azure.Mcp.Tools.Monitor.Models.HealthModels; +using Azure.Mcp.Tools.Monitor.Services; +using Microsoft.Mcp.Core.Options; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.Monitor.Tests.HealthModels; + +public class HealthModelGetCommandTests : SubscriptionCommandUnitTestsBase +{ + private const string TestSubscription = "sub123"; + private const string TestResourceGroup = "rg1"; + private const string TestHealthModel = "hm-one"; + + [Fact] + public async Task ExecuteAsync_ReturnsHealthModel_WhenItExists() + { + Service.GetHealthModel(TestSubscription, TestResourceGroup, TestHealthModel, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new HealthModelDetail + { + Id = null, + Name = "hm-one", + ResourceGroup = "rg1", + Location = "eastus2", + ProvisioningState = "Succeeded", + HealthState = "Healthy", + Tags = null, + }); + + var response = await ExecuteCommandAsync("--subscription", TestSubscription, "--resource-group", TestResourceGroup, "--health-model", TestHealthModel); + + Assert.Equal(HttpStatusCode.OK, response.Status); + var result = ValidateAndDeserializeResponse(response, MonitorJsonContext.Default.HealthModelGetCommandResult); + Assert.Equal("hm-one", result!.HealthModel.Name); + Assert.Equal("Succeeded", result.HealthModel.ProvisioningState); + Assert.Equal("Healthy", result.HealthModel.HealthState); + } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/HealthModelListCommandTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/HealthModelListCommandTests.cs new file mode 100644 index 0000000000..c13213a3be --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/HealthModelListCommandTests.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tests.Commands; +using Azure.Mcp.Tools.Monitor.Commands; +using Azure.Mcp.Tools.Monitor.Commands.HealthModels; +using Azure.Mcp.Tools.Monitor.Models.HealthModels; +using Azure.Mcp.Tools.Monitor.Services; +using Microsoft.Mcp.Core.Options; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.Monitor.Tests.HealthModels; + +public class HealthModelListCommandTests : SubscriptionCommandUnitTestsBase +{ + private const string TestSubscription = "sub123"; + private const string TestResourceGroup = "rg1"; + + [Fact] + public async Task ExecuteAsync_ReturnsLeanSummaries_WhenTheyExist() + { + List summaries = + [ + new() { Id = "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.CloudHealth/healthmodels/hm-one", Name = "hm-one", ResourceGroup = "rg1", Location = "eastus2", ProvisioningState = "Succeeded" }, + new() { Id = "/subscriptions/sub123/resourceGroups/rg2/providers/Microsoft.CloudHealth/healthmodels/hm-two", Name = "hm-two", ResourceGroup = "rg2", Location = "westus2", ProvisioningState = "Provisioning" }, + ]; + Service.ListHealthModels(TestSubscription, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(summaries); + + var response = await ExecuteCommandAsync("--subscription", TestSubscription); + + Assert.Equal(HttpStatusCode.OK, response.Status); + var result = ValidateAndDeserializeResponse(response, MonitorJsonContext.Default.ListHealthModelSummary); + Assert.Equal(2, result.Count); + Assert.Equal("hm-one", result[0].Name); + Assert.Equal("rg1", result[0].ResourceGroup); + Assert.Equal("eastus2", result[0].Location); + Assert.Equal("Succeeded", result[0].ProvisioningState); + Assert.Equal("Provisioning", result[1].ProvisioningState); + + // Lean by construction: each serialized item carries ONLY the summary keys (no ARM envelope). + var json = System.Text.Json.JsonSerializer.Serialize(result, MonitorJsonContext.Default.ListHealthModelSummary); + var array = System.Text.Json.Nodes.JsonNode.Parse(json)!.AsArray(); + foreach (var item in array) + { + var keys = ((System.Text.Json.Nodes.JsonObject)item!).Select(kv => kv.Key).OrderBy(k => k, StringComparer.Ordinal).ToArray(); + Assert.Equal(["id", "location", "name", "provisioningState", "resourceGroup"], keys); + } + } + + [Fact] + public async Task ExecuteAsync_ForwardsResourceGroup_WhenProvided() + { + Service.ListHealthModels(Arg.Any(), Arg.Is(TestResourceGroup), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + + var response = await ExecuteCommandAsync("--subscription", TestSubscription, "--resource-group", TestResourceGroup); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await Service.Received(1).ListHealthModels(Arg.Any(), Arg.Is(TestResourceGroup), Arg.Any(), Arg.Any(), Arg.Any()); + } +}