From e1e3c8b2b1d84c79c315605a42557571fcface94 Mon Sep 17 00:00:00 2001 From: qianwens Date: Thu, 17 Jul 2025 15:55:15 +0800 Subject: [PATCH 01/56] onboard code to cloud tools --- .github/CODEOWNERS | 6 + .vscode/cspell.json | 72 +++- Directory.Packages.props | 10 + docs/azmcp-commands.md | 15 + e2eTests/e2eTestPrompts.md | 11 + .../Deploy/Commands/AzdAppLogGetCommand.cs | 81 ++++ src/Areas/Deploy/Commands/Consts.cs | 40 ++ .../Deploy/Commands/DeployJsonContext.cs | 28 ++ src/Areas/Deploy/Commands/EncodeMermaid.cs | 70 ++++ .../GenerateArchitectureDiagramCommand.cs | 117 ++++++ .../Deploy/Commands/GenerateMermaidChart.cs | 147 +++++++ .../Commands/InfraCodeRulesGetCommand.cs | 83 ++++ src/Areas/Deploy/Commands/MermaidData.cs | 21 + .../Commands/PipelineGenerateCommand.cs | 78 ++++ src/Areas/Deploy/Commands/PlanGetCommand.cs | 85 ++++ .../Deploy/Commands/QuotaCheckCommand.cs | 92 +++++ .../Deploy/Commands/RegionCheckCommand.cs | 107 +++++ src/Areas/Deploy/DeploySetup.cs | 43 +++ .../Models/AzureRegionCheckParameters.cs | 10 + .../Deploy/Models/InfraCodeRulesParameters.cs | 24 ++ src/Areas/Deploy/Options/AppTopology.cs | 70 ++++ .../Deploy/Options/DeployAppLogOptions.cs | 19 + .../Deploy/Options/DeployOptionDefinitions.cs | 365 ++++++++++++++++++ .../Deploy/Options/InfraCodeRulesOptions.cs | 11 + .../Deploy/Options/PipelineGenerateOptions.cs | 22 ++ src/Areas/Deploy/Options/PlanGetOptions.cs | 24 ++ src/Areas/Deploy/Options/QuotaCheckOptions.cs | 16 + .../Deploy/Options/RawMcpToolInputOptions.cs | 15 + .../Deploy/Options/RegionCheckOptions.cs | 22 ++ src/Areas/Deploy/Services/DeployService.cs | 90 +++++ src/Areas/Deploy/Services/IDeployService.cs | 30 ++ .../Services/Util/AzdAppLogRetriever.cs | 239 ++++++++++++ .../Services/Util/AzdResourceLogService.cs | 108 ++++++ .../Services/Util/AzureRegionChecker.cs | 225 +++++++++++ .../Util/DeploymentPlanTemplateUtil.cs | 164 ++++++++ .../Services/Util/InfraCodeRuleRetriever.cs | 188 +++++++++ .../Deploy/Services/Util/JsonElementHelper.cs | 15 + .../Services/Util/PipelineGenerationUtil.cs | 72 ++++ .../Util/QuotaChecker/AzureQuotaChecker.cs | 179 +++++++++ .../CognitiveServicesQuotaChecker.cs | 36 ++ .../Util/QuotaChecker/ComputeQuotaChecker.cs | 36 ++ .../QuotaChecker/ContainerAppQuotaChecker.cs | 35 ++ .../ContainerInstanceQuotaChecker.cs | 35 ++ .../QuotaChecker/HDInsightQuotaChecker.cs | 34 ++ .../MachineLearningQuotaChecker.cs | 34 ++ .../Util/QuotaChecker/NetworkQuotaChecker.cs | 34 ++ .../QuotaChecker/PostgreSQLQuotaChecker.cs | 59 +++ .../Util/QuotaChecker/SearchQuotaChecker.cs | 35 ++ .../Util/QuotaChecker/StorageQuotaChecker.cs | 36 ++ .../ToolLoading/CommandFactoryToolLoader.cs | 39 +- src/AzureMcp.csproj | 10 + src/Commands/CommandExtensions.cs | 26 ++ src/Program.cs | 1 + .../Deploy/LiveTests/DeployCommandTests.cs | 237 ++++++++++++ .../UnitTests/ArchitectureDiagramTests.cs | 93 +++++ .../CommandFactoryToolLoaderTests.cs | 56 +++ .../Extensions/CommandExtensionsTests.cs | 21 + 57 files changed, 3851 insertions(+), 20 deletions(-) create mode 100644 src/Areas/Deploy/Commands/AzdAppLogGetCommand.cs create mode 100644 src/Areas/Deploy/Commands/Consts.cs create mode 100644 src/Areas/Deploy/Commands/DeployJsonContext.cs create mode 100644 src/Areas/Deploy/Commands/EncodeMermaid.cs create mode 100644 src/Areas/Deploy/Commands/GenerateArchitectureDiagramCommand.cs create mode 100644 src/Areas/Deploy/Commands/GenerateMermaidChart.cs create mode 100644 src/Areas/Deploy/Commands/InfraCodeRulesGetCommand.cs create mode 100644 src/Areas/Deploy/Commands/MermaidData.cs create mode 100644 src/Areas/Deploy/Commands/PipelineGenerateCommand.cs create mode 100644 src/Areas/Deploy/Commands/PlanGetCommand.cs create mode 100644 src/Areas/Deploy/Commands/QuotaCheckCommand.cs create mode 100644 src/Areas/Deploy/Commands/RegionCheckCommand.cs create mode 100644 src/Areas/Deploy/DeploySetup.cs create mode 100644 src/Areas/Deploy/Models/AzureRegionCheckParameters.cs create mode 100644 src/Areas/Deploy/Models/InfraCodeRulesParameters.cs create mode 100644 src/Areas/Deploy/Options/AppTopology.cs create mode 100644 src/Areas/Deploy/Options/DeployAppLogOptions.cs create mode 100644 src/Areas/Deploy/Options/DeployOptionDefinitions.cs create mode 100644 src/Areas/Deploy/Options/InfraCodeRulesOptions.cs create mode 100644 src/Areas/Deploy/Options/PipelineGenerateOptions.cs create mode 100644 src/Areas/Deploy/Options/PlanGetOptions.cs create mode 100644 src/Areas/Deploy/Options/QuotaCheckOptions.cs create mode 100644 src/Areas/Deploy/Options/RawMcpToolInputOptions.cs create mode 100644 src/Areas/Deploy/Options/RegionCheckOptions.cs create mode 100644 src/Areas/Deploy/Services/DeployService.cs create mode 100644 src/Areas/Deploy/Services/IDeployService.cs create mode 100644 src/Areas/Deploy/Services/Util/AzdAppLogRetriever.cs create mode 100644 src/Areas/Deploy/Services/Util/AzdResourceLogService.cs create mode 100644 src/Areas/Deploy/Services/Util/AzureRegionChecker.cs create mode 100644 src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs create mode 100644 src/Areas/Deploy/Services/Util/InfraCodeRuleRetriever.cs create mode 100644 src/Areas/Deploy/Services/Util/JsonElementHelper.cs create mode 100644 src/Areas/Deploy/Services/Util/PipelineGenerationUtil.cs create mode 100644 src/Areas/Deploy/Services/Util/QuotaChecker/AzureQuotaChecker.cs create mode 100644 src/Areas/Deploy/Services/Util/QuotaChecker/CognitiveServicesQuotaChecker.cs create mode 100644 src/Areas/Deploy/Services/Util/QuotaChecker/ComputeQuotaChecker.cs create mode 100644 src/Areas/Deploy/Services/Util/QuotaChecker/ContainerAppQuotaChecker.cs create mode 100644 src/Areas/Deploy/Services/Util/QuotaChecker/ContainerInstanceQuotaChecker.cs create mode 100644 src/Areas/Deploy/Services/Util/QuotaChecker/HDInsightQuotaChecker.cs create mode 100644 src/Areas/Deploy/Services/Util/QuotaChecker/MachineLearningQuotaChecker.cs create mode 100644 src/Areas/Deploy/Services/Util/QuotaChecker/NetworkQuotaChecker.cs create mode 100644 src/Areas/Deploy/Services/Util/QuotaChecker/PostgreSQLQuotaChecker.cs create mode 100644 src/Areas/Deploy/Services/Util/QuotaChecker/SearchQuotaChecker.cs create mode 100644 src/Areas/Deploy/Services/Util/QuotaChecker/StorageQuotaChecker.cs create mode 100644 tests/Areas/Deploy/LiveTests/DeployCommandTests.cs create mode 100644 tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9b8cdc249..831c8ff59 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -147,6 +147,12 @@ # ServiceLabel: %area-Authorization # ServiceOwners: @vurhanau +# PRLabel: %area-Deploy +/src/Areas/Deploy/ @qianwens @xiaofanzhou @Azure/azure-mcp + +# ServiceLabel: %area-Deploy +# ServiceOwners: @qianwens @xiaofanzhou + # PRLabel: %area-LoadTesting /src/Areas/LoadTesting/ @nishtha489 @knarayanana @krchanda @johnsta @Azure/azure-mcp diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 28c407af8..2c66babd2 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -164,15 +164,44 @@ "alcoop", "Apim", "appconfig", + "appservice", + "azapi", "azmcp", - "azuremcp", "azsdk", + "aztfmod", + "aztfmod", + "azureaisearch", + "azureaiservices", + "azureapplicationinsights", + "azureappservice", "azureblob", + "azurebotservice", + "azurecacheforredis", + "azurecaf", + "azurecontainerapp", + "azurecosmosdb", + "azurecosmosdb", + "azuredatabaseformysql", + "azuredatabaseforpostgresql", + "azuredocs", + "azurefunctions", + "azurekeyvault", "azuremcp", - "azuresdk", - "azuretools", + "azureopenai", + "azureprivateendpoint", "azureresourcegroups", + "azurerm", + "azuresdk", + "azureservicebus", + "azuresignalrservice", + "azuresqldatabase", + "azurestaticwebapps", + "azurestorage", + "azurestorageaccount", "azureterraformbestpractices", + "azuretools", + "azurevirtualnetwork", + "azurewebpubsub", "azurefunctions", "codegen", "codeium", @@ -181,14 +210,21 @@ "bestpractices", "bicepschema", "breathability", - "codesign", + "cicd", + "codeium", "CODEOWNERS", + "codesign", + "cognitiveservices", + "containerapp", "containerapps", + "copilotmd", + "cslschema", "cvzf", "dataplane", "datalake", "datasource", "datasources", + "dbforpostgresql", "Distributedtask", "drawcord", "enumerables", @@ -206,28 +242,41 @@ "loadtests", "esrp", "ESRPRELPACMANTEST", + "exfiltration", "facetable", + "filefilters", + "functionapp", + "gethealth", + "healthmodels", "hnsw", "idtyp", + "jsonencode", + "kcsb", + "keyspace", "keyvault", "Kusto", + "Kusto", + "kvps", "ligar", - "Linq", "linkedservices", + "Linq", "LINUXOS", "LINUXPOOL", "LINUXVMIMAGE", "LLM", + "loadtest", + "loadtesting", "loadtestrun", + "loadtests", + "MACOS", + "MACPOOL", + "MACVMIMAGE", "midsole", "monitoredresources", "msal", "myaccount", - "mysvc", "mycluster", - "MACOS", - "MACPOOL", - "MACVMIMAGE", + "mysvc", "Newtonsoft", "Npgsql", "npmjs", @@ -253,13 +302,16 @@ "searchdocs", "servicebus", "setparam", + "siteextensions", "skillset", - "syslib", "skillsets", + "staticwebapp", "submode", + "syslib", "testresource", "testrun", "testsettings", + "tfvars", "testfilesystem", "timespan", "toolsets", diff --git a/Directory.Packages.props b/Directory.Packages.props index 16cfb392e..02c3ed184 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -34,6 +34,15 @@ + + + + + + + + + @@ -45,6 +54,7 @@ + diff --git a/docs/azmcp-commands.md b/docs/azmcp-commands.md index 8b3d62ad1..286240548 100644 --- a/docs/azmcp-commands.md +++ b/docs/azmcp-commands.md @@ -662,6 +662,21 @@ azmcp azureterraformbestpractices get azmcp bicepschema get --resource-type \ ``` +### Deploy +```bash +# Check the Azure quota availability for the resources type +azmcp deploy quota-check --subscription \ + --region \ + --resource-types + +# Get the available regions for the resources types +azmcp deploy available-region-get --subscription \ + --resource-types \ + [--cognitive-service-model-name ] \ + [--cognitive-service-model-version ] \ + [--cognitive-service-deployment-sku-name ] +``` + ## Response Format All responses follow a consistent JSON format: diff --git a/e2eTests/e2eTestPrompts.md b/e2eTests/e2eTestPrompts.md index eb5604a6f..839ec3e5c 100644 --- a/e2eTests/e2eTestPrompts.md +++ b/e2eTests/e2eTestPrompts.md @@ -280,3 +280,14 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | Tool Name | Test Prompt | |:----------|:----------| | azmcp-bicepschema-get | How can I use Bicep to create an Azure OpenAI service? | + +## Deploy +| Tool Name | Test Prompt | +|:----------|:----------| +| azmcp-deploy-plan-get | Create a plan to deploy this application to azure | +| azmcp-deploy-infra-code-rules-get | Show me the rules to generate bicep scripts | +| azmcp-deploy-available-region-get | Show me the available regions for these resource types | +| azmcp-deploy-quota-check | Check if there is quota for in region | +| azmcp-deploy-azd-app-log-get | Show me the log of the application deployed by azd | +| azmcp-deploy-cicd-pipeline-guidance-get | How to create cicd pipeline to deploy this app to azure | +| azmcp-deploy-architecture-diagram-generate | Generate the azure architecture diagram for this application | \ No newline at end of file diff --git a/src/Areas/Deploy/Commands/AzdAppLogGetCommand.cs b/src/Areas/Deploy/Commands/AzdAppLogGetCommand.cs new file mode 100644 index 000000000..2ef3e6b30 --- /dev/null +++ b/src/Areas/Deploy/Commands/AzdAppLogGetCommand.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AzureMcp.Areas.Deploy.Options; +using AzureMcp.Areas.Deploy.Services; +using AzureMcp.Commands.Subscription; +using AzureMcp.Services.Telemetry; +using Microsoft.Extensions.Logging; + +namespace AzureMcp.Areas.Deploy.Commands; + +public sealed class AzdAppLogGetCommand(ILogger logger) : SubscriptionCommand() +{ + private const string CommandTitle = "Get AZD deployed App Logs"; + private readonly ILogger _logger = logger; + + private readonly Option _workspaceFolderOption = DeployOptionDefinitions.AzdAppLogOptions.WorkspaceFolder; + private readonly Option _azdEnvNameOption = DeployOptionDefinitions.AzdAppLogOptions.AzdEnvName; + private readonly Option _limitOption = DeployOptionDefinitions.AzdAppLogOptions.Limit; + + public override string Name => "azd-app-log-get"; + + public override string Description => + """ + This tool helps fetch logs from log analytics workspace for Container Apps, App Services, function apps that were deployed through azd. Invoke this tool directly after a successful `azd up` or when user prompts to check the app's status or provide errors in the deployed apps. + """; + + public override string Title => CommandTitle; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.AddOption(_workspaceFolderOption); + command.AddOption(_azdEnvNameOption); + command.AddOption(_limitOption); + } + + protected override AzdAppLogOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.WorkspaceFolder = parseResult.GetValueForOption(_workspaceFolderOption)!; + options.AzdEnvName = parseResult.GetValueForOption(_azdEnvNameOption)!; + options.Limit = parseResult.GetValueForOption(_limitOption); + return options; + } + + [McpServerTool(Destructive = false, ReadOnly = true, Title = CommandTitle)] + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + var options = BindOptions(parseResult); + + try + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + context.Activity?.WithSubscriptionTag(options); + + // Parse optional date parameters + + var deployService = context.GetService(); + string result = await deployService.GetAzdResourceLogsAsync( + options.WorkspaceFolder!, + options.AzdEnvName!, + options.Subscription!, + options.Limit); + + context.Response.Message = result; + } + catch (Exception ex) + { + _logger.LogError(ex, "An exception occurred getting azd app logs."); + HandleException(context, ex); + } + + return context.Response; + } + +} diff --git a/src/Areas/Deploy/Commands/Consts.cs b/src/Areas/Deploy/Commands/Consts.cs new file mode 100644 index 000000000..e3b68f5b6 --- /dev/null +++ b/src/Areas/Deploy/Commands/Consts.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AzureMcp.Areas.Deploy.Commands; + +public static class AzureServiceConstants +{ + public enum AzureComputeServiceType + { + AppService, + FunctionApp, + ContainerApp, + StaticWebApp + } + + public enum AzureServiceType + { + AzureAISearch, + AzureAIServices, + AppService, + AzureApplicationInsights, + AzureBotService, + AzureContainerApp, + AzureCosmosDB, + AzureFunctionApp, + AzureKeyVault, + AzureDatabaseForMySQL, + AzureOpenAI, + AzureDatabaseForPostgreSQL, + AzurePrivateEndpoint, + AzureRedisCache, + AzureSQLDatabase, + AzureStorageAccount, + StaticWebApp, + AzureServiceBus, + AzureSignalRService, + AzureVirtualNetwork, + AzureWebPubSub + } +} diff --git a/src/Areas/Deploy/Commands/DeployJsonContext.cs b/src/Areas/Deploy/Commands/DeployJsonContext.cs new file mode 100644 index 000000000..b85a4956b --- /dev/null +++ b/src/Areas/Deploy/Commands/DeployJsonContext.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using AzureMcp.Areas.Deploy.Options; +using AzureMcp.Areas.Deploy.Models; +using AzureMcp.Areas.Deploy.Commands.Quota; +using AzureMcp.Areas.Deploy.Commands.Region; +using Areas.Deploy.Services.Util; + +namespace AzureMcp.Areas.Deploy.Commands; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull +)] +[JsonSerializable(typeof(AppTopology))] +[JsonSerializable(typeof(MermaidData))] +[JsonSerializable(typeof(MermaidConfig))] +[JsonSerializable(typeof(QuotaCheckCommand.QuotaCheckCommandResult))] +[JsonSerializable(typeof(QuotaInfo))] +[JsonSerializable(typeof(Dictionary>))] +[JsonSerializable(typeof(RegionCheckCommand.RegionCheckCommandResult))] +[JsonSerializable(typeof(List))] +internal sealed partial class DeployJsonContext : JsonSerializerContext +{ +} diff --git a/src/Areas/Deploy/Commands/EncodeMermaid.cs b/src/Areas/Deploy/Commands/EncodeMermaid.cs new file mode 100644 index 000000000..efa64ebfc --- /dev/null +++ b/src/Areas/Deploy/Commands/EncodeMermaid.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IO.Compression; +using System.Text; + +namespace AzureMcp.Areas.Deploy.Commands; + +public static class EncodeMermaid +{ + public static string GetEncodedMermaidChart(string graph) + { + var data = new MermaidData + { + Code = graph, + Mermaid = new MermaidConfig { Theme = "default" } + }; + + string jsonString = JsonSerializer.Serialize(data, DeployJsonContext.Default.MermaidData); + + byte[] encodedData = Encoding.UTF8.GetBytes(jsonString); + + byte[] compressedGraph = CompressData(encodedData); + + string base64CompressedGraph = Convert.ToBase64String(compressedGraph); + + return base64CompressedGraph; + } + + public static string GetDecodedMermaidChart(string encodedChart) + { + byte[] compressedData = Convert.FromBase64String(encodedChart); + + byte[] decompressedData = DecompressData(compressedData); + + string jsonString = Encoding.UTF8.GetString(decompressedData); + + MermaidData? data = JsonSerializer.Deserialize(jsonString, DeployJsonContext.Default.MermaidData); + + return data?.Code ?? string.Empty; + } + + private static byte[] CompressData(byte[] data) + { + using (var memoryStream = new MemoryStream()) + { + using (var deflateStream = new GZipStream(memoryStream, CompressionMode.Compress)) + { + deflateStream.Write(data, 0, data.Length); + } + return memoryStream.ToArray(); + } + } + + private static byte[] DecompressData(byte[] compressedData) + { + using (var memoryStream = new MemoryStream(compressedData)) + { + using (var deflateStream = new GZipStream(memoryStream, CompressionMode.Decompress)) + { + using (var outputStream = new MemoryStream()) + { + deflateStream.CopyTo(outputStream); + return outputStream.ToArray(); + } + } + } + } +} + diff --git a/src/Areas/Deploy/Commands/GenerateArchitectureDiagramCommand.cs b/src/Areas/Deploy/Commands/GenerateArchitectureDiagramCommand.cs new file mode 100644 index 000000000..73852651a --- /dev/null +++ b/src/Areas/Deploy/Commands/GenerateArchitectureDiagramCommand.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using System.Text.Json; +using AzureMcp.Commands; +using AzureMcp.Helpers; +using Microsoft.Extensions.Logging; +using AzureMcp.Areas.Deploy.Options; +using System.Text.Json.Nodes; + +namespace AzureMcp.Areas.Deploy.Commands; + +public sealed class GenerateArchitectureDiagramCommand(ILogger logger) : BaseCommand() +{ + private const string CommandTitle = "Generate Architecture Diagram"; + private readonly ILogger _logger = logger; + + public override string Name => "architecture-diagram-generate"; + + private readonly Option _rawMcpToolInputOption = DeployOptionDefinitions.RawMcpToolInput.RawMcpToolInputOption; + + public override string Description => + "Generates an azure service architecture diagram for the application based on the provided app topology." + + "Call this tool when the user need recommend or design the azure architecture of their application." + + "Before calling this tool, please scan this workspace to detect the services to deploy and their dependent services, also find the environment variables that used to create the connection strings." + + "If it's a .NET Aspire application, check aspireManifest.json file if there is. Try your best to fulfill the input schema with your analyze result."; + + public override string Title => "Generate Architecture Diagram"; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.AddOption(_rawMcpToolInputOption); + } + + private RawMcpToolInputOptions BindOptions(ParseResult parseResult) + { + var options = new RawMcpToolInputOptions(); + options.RawMcpToolInput = parseResult.GetValueForOption(_rawMcpToolInputOption); + return options; + } + + [McpServerTool(Destructive = false, ReadOnly = true, Title = CommandTitle)] + public override Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + try + { + var options = BindOptions(parseResult); + var rawMcpToolInput = options.RawMcpToolInput; + if (string.IsNullOrWhiteSpace(rawMcpToolInput)) + { + throw new ArgumentException("App topology cannot be null or empty.", nameof(options.RawMcpToolInput)); + } + + AppTopology appTopology; + try + { + appTopology = JsonSerializer.Deserialize(rawMcpToolInput, DeployJsonContext.Default.AppTopology) + ?? throw new ArgumentException("Failed to deserialize app topology.", nameof(rawMcpToolInput)); + } + catch (JsonException ex) + { + throw new ArgumentException($"Invalid JSON format: {ex.Message}", nameof(rawMcpToolInput), ex); + } + + _logger.LogInformation("Successfully parsed app topology with {ServiceCount} services", appTopology.Services.Length); + + if (appTopology.Services.Length == 0) + { + _logger.LogWarning("No services detected in the app topology."); + context.Response.Status = 200; + context.Response.Message = "No service detected."; + return Task.FromResult(context.Response); + } + + var chart = GenerateMermaidChart.GenerateChart(appTopology.WorkspaceFolder ?? "", appTopology); + if (string.IsNullOrWhiteSpace(chart)) + { + throw new InvalidOperationException("Failed to generate architecture diagram. The chart content is empty."); + } + var encodedDiagram = EncodeMermaid.GetEncodedMermaidChart(chart); + + var mermaidUrl = $"https://mermaid.live/view#pako:{encodedDiagram}"; + _logger.LogInformation("Generated architecture diagram successfully. Mermaid URL: {MermaidUrl}", mermaidUrl); + + var usedServiceTypes = appTopology.Services + .SelectMany(service => service.Dependencies) + .Select(dep => dep.ServiceType) + .Where(serviceType => !string.IsNullOrWhiteSpace(serviceType)) + .Where(serviceType => Enum.GetNames().Contains(serviceType, StringComparer.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x) + .ToArray(); + + var usedServiceTypesString = usedServiceTypes.Length > 0 + ? string.Join(", ", usedServiceTypes) + : null; + + context.Response.Message = $"Help the user open up this URI to preview their app topology using tool open_simple_browser: {mermaidUrl} \n" + + "Ask user if the topology is expected, if not, you should call this tool with the user's updated instructions. " + + "Please inform the user that here are the supported hosting technologies: " + + $"{string.Join(", ", Enum.GetNames())}. "; + if (!string.IsNullOrWhiteSpace(usedServiceTypesString)) + { + context.Response.Message += $"Here is the full list of supported component service types for the topology: {usedServiceTypesString}."; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to generate architecture diagram."); + HandleException(context, ex); + } + + return Task.FromResult(context.Response); + } +} diff --git a/src/Areas/Deploy/Commands/GenerateMermaidChart.cs b/src/Areas/Deploy/Commands/GenerateMermaidChart.cs new file mode 100644 index 000000000..4c032b5a9 --- /dev/null +++ b/src/Areas/Deploy/Commands/GenerateMermaidChart.cs @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using AzureMcp.Areas.Deploy.Options; + +namespace AzureMcp.Areas.Deploy.Commands; + +public static class GenerateMermaidChart +{ + public static string GenerateChart(string workspaceFolder, AppTopology appTopology) + { + var chartComponents = new List(); + + chartComponents.Add("graph TD"); + + chartComponents.Add(""" + %% Define styles + classDef service fill:#50e5ff,stroke:#333,stroke-width:2px,color:#000 + classDef compute fill:#9cf00b,stroke:#333,stroke-width:2px,color:#000 + classDef binding fill:#fef200,stroke:#333,stroke-width:2px,color:#000 + """); + + var services = new List { "%% Services" }; + var resources = new List { "%% Resources" }; + var relationships = new List { "%% Relationships" }; + + foreach (var service in appTopology.Services) + { + var serviceName = new List { $"Name: {service.Name}" }; + + var projectRelativePath = Path.GetRelativePath(workspaceFolder, string.IsNullOrWhiteSpace(service.Path) ? workspaceFolder : service.Path); + serviceName.Add($"Path: {projectRelativePath}"); + serviceName.Add($"Language: {service.Language}"); + serviceName.Add($"Port: {service.Port}"); + + if (service.DockerSettings != null && + string.Equals(service.AzureComputeHost, "azurecontainerapp", StringComparison.OrdinalIgnoreCase)) + { + serviceName.Add($"DockerFile: {service.DockerSettings.DockerFilePath}"); + serviceName.Add($"Docker Context: {service.DockerSettings.DockerContext}"); + } + + var serviceInternalName = $"svc-{service.Name}"; + + services.Add(CreateComponentName(serviceInternalName, string.Join("\n", serviceName), "service", NodeShape.Rectangle)); + + relationships.Add(CreateRelationshipString(serviceInternalName, service.Name, "hosted on", ArrowType.Solid)); + } + + foreach (var service in appTopology.Services) + { + foreach (var dependency in service.Dependencies) + { + var instanceInternalName = $"{FlattenServiceType(dependency.ServiceType)}.{dependency.Name}"; + var instanceName = $"{dependency.Name} ({dependency.ServiceType})"; + + if (IsComputeResourceType(dependency.ServiceType)) + { + resources.Add(CreateComponentName(instanceInternalName, instanceName, "compute", NodeShape.RoundedRectangle)); + } + else + { + resources.Add(CreateComponentName(instanceInternalName, instanceName, "binding", NodeShape.Circle)); + } + + relationships.Add(CreateRelationshipString(service.Name, instanceInternalName, dependency.ConnectionType, ArrowType.Dotted)); + } + } + + chartComponents.AddRange(services); + chartComponents.AddRange(resources); + chartComponents.AddRange(relationships); + + return string.Join("\n", chartComponents); + } + + private static string CreateComponentName(string internalName, string name, string type, NodeShape nodeShape) + { + var nodeShapeBrackets = GetNodeShapeBrackets(nodeShape); + return $"{EnsureUrlFriendlyName(internalName)}{nodeShapeBrackets[0]}\"`{name}`\"{nodeShapeBrackets[1]}:::{type}"; + } + + private static string CreateRelationshipString(string sourceName, string targetName, string connectionDescription, ArrowType arrowType) + { + var arrowSymbol = GetArrowSymbol(arrowType); + return $"{EnsureUrlFriendlyName(sourceName)} {arrowSymbol} |\"{connectionDescription}\"| {EnsureUrlFriendlyName(targetName)}"; + } + + private static string EnsureUrlFriendlyName(string name) + { + return name.Replace('.', '_') + .Replace(" ", "_") + .Trim() + .ToLowerInvariant(); + } + + private static string[] GetNodeShapeBrackets(NodeShape nodeShape) + { + return nodeShape switch + { + NodeShape.Rectangle => ["[", "]"], + NodeShape.Circle => ["((", "))"], + NodeShape.RoundedRectangle => ["(", ")"], + NodeShape.Cylinder => ["[(", ")]"], + NodeShape.Hexagon => ["{{", "}}"], + _ => ["[", "]"] + }; + } + + private static string GetArrowSymbol(ArrowType arrowType) + { + return arrowType switch + { + ArrowType.Solid => "-->", + ArrowType.Open => "->", + ArrowType.Dotted => "-.->", + _ => "-->" + }; + } + + private static string FlattenServiceType(string serviceType) + { + return serviceType.ToLowerInvariant().Replace("azure", ""); + } + + private static bool IsComputeResourceType(string serviceType) + { + return Enum.GetNames().Contains(serviceType, StringComparer.OrdinalIgnoreCase); + } +} + +public enum NodeShape +{ + Rectangle, + Circle, + RoundedRectangle, + Cylinder, + Hexagon +} + +public enum ArrowType +{ + Solid, + Open, + Dotted +} diff --git a/src/Areas/Deploy/Commands/InfraCodeRulesGetCommand.cs b/src/Areas/Deploy/Commands/InfraCodeRulesGetCommand.cs new file mode 100644 index 000000000..f65cd4444 --- /dev/null +++ b/src/Areas/Deploy/Commands/InfraCodeRulesGetCommand.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using AzureMcp.Areas.Deploy.Models; +using AzureMcp.Areas.Deploy.Options; +using AzureMcp.Areas.Deploy.Services.Util; +using AzureMcp.Commands; +using Microsoft.Extensions.Logging; + +namespace AzureMcp.Areas.Deploy.Commands.InfraCodeRules; + +public sealed class InfraCodeRulesGetCommand(ILogger logger) + : BaseCommand() +{ + private const string CommandTitle = "Get Infrastructure Code Rules"; + private readonly ILogger _logger = logger; + + private readonly Option _deploymentToolOption = DeployOptionDefinitions.InfraCodeRules.DeploymentTool; + private readonly Option _iacTypeOption = DeployOptionDefinitions.InfraCodeRules.IacType; + private readonly Option _resourceTypesOption = DeployOptionDefinitions.InfraCodeRules.ResourceTypes; + + public override string Name => "infra-code-rules-get"; + + public override string Description => + """ + This tool offers guidelines for creating Bicep/Terraform files to deploy applications on Azure. The guidelines outline rules to improve the quality of Infrastructure as Code files, ensuring they are compatible with the azd tool and adhere to best practices. + """; + + public override string Title => CommandTitle; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.AddOption(_deploymentToolOption); + command.AddOption(_iacTypeOption); + command.AddOption(_resourceTypesOption); + } + + private InfraCodeRulesOptions BindOptions(ParseResult parseResult) + { + var options = new InfraCodeRulesOptions(); + options.DeploymentTool = parseResult.GetValueForOption(_deploymentToolOption) ?? string.Empty; + options.IacType = parseResult.GetValueForOption(_iacTypeOption) ?? string.Empty; + options.ResourceTypes = parseResult.GetValueForOption(_resourceTypesOption) ?? string.Empty; + + return options; + } + + [McpServerTool( + Destructive = false, + ReadOnly = true, + Title = CommandTitle)] + public override Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + var options = BindOptions(parseResult); + try + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return Task.FromResult(context.Response); + } + + var resourceTypes = options.ResourceTypes.Split(',') + .Select(rt => rt.Trim()) + .Where(rt => !string.IsNullOrWhiteSpace(rt)) + .ToArray(); + + List result = InfraCodeRuleRetriever.GetInfraCodeRules( + options.DeploymentTool, + options.IacType, + resourceTypes); + + context.Response.Message = string.Join(Environment.NewLine, result); + } + catch (Exception ex) + { + _logger.LogError(ex, "An exception occurred listing accounts."); + HandleException(context, ex); + } + return Task.FromResult(context.Response); + } +} diff --git a/src/Areas/Deploy/Commands/MermaidData.cs b/src/Areas/Deploy/Commands/MermaidData.cs new file mode 100644 index 000000000..9b4c0a2ae --- /dev/null +++ b/src/Areas/Deploy/Commands/MermaidData.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace AzureMcp.Areas.Deploy.Commands; + +public sealed class MermaidData +{ + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("mermaid")] + public MermaidConfig Mermaid { get; set; } = new(); +} + +public sealed class MermaidConfig +{ + [JsonPropertyName("theme")] + public string Theme { get; set; } = "default"; +} diff --git a/src/Areas/Deploy/Commands/PipelineGenerateCommand.cs b/src/Areas/Deploy/Commands/PipelineGenerateCommand.cs new file mode 100644 index 000000000..69365d326 --- /dev/null +++ b/src/Areas/Deploy/Commands/PipelineGenerateCommand.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AzureMcp.Areas.Deploy.Options; +using AzureMcp.Areas.Deploy.Services.Util; +using AzureMcp.Commands.Subscription; +using AzureMcp.Services.Telemetry; +using Microsoft.Extensions.Logging; + +namespace AzureMcp.Areas.Deploy.Commands; + +public sealed class PipelineGenerateCommand(ILogger logger) + : SubscriptionCommand() +{ + private const string CommandTitle = "Get Azure Deployment CICD Pipeline Guidance"; + private readonly ILogger _logger = logger; + + private readonly Option _useAZDPipelineConfigOption = DeployOptionDefinitions.PipelineGenerateOptions.UseAZDPipelineConfig; + private readonly Option _organizationNameOption = DeployOptionDefinitions.PipelineGenerateOptions.OrganizationName; + private readonly Option _repositoryNameOption = DeployOptionDefinitions.PipelineGenerateOptions.RepositoryName; + private readonly Option _githubEnvironmentNameOption = DeployOptionDefinitions.PipelineGenerateOptions.GithubEnvironmentName; + + public override string Name => "cicd-pipeline-guidance-get"; + + public override string Description => + """ + Guidance to create a CI/CD pipeline which provision Azure resources and build and deploy applications to Azure. Use this tool BEFORE generating/creating a Github actions workflow file for DEPLOYMENT on Azure. Infrastructure files should be ready and the application should be ready to be containerized. + """; + + public override string Title => CommandTitle; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.AddOption(_useAZDPipelineConfigOption); + command.AddOption(_organizationNameOption); + command.AddOption(_repositoryNameOption); + command.AddOption(_githubEnvironmentNameOption); + } + + protected override PipelineGenerateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.UseAZDPipelineConfig = parseResult.GetValueForOption(_useAZDPipelineConfigOption); + options.OrganizationName = parseResult.GetValueForOption(_organizationNameOption); + options.RepositoryName = parseResult.GetValueForOption(_repositoryNameOption); + options.GithubEnvironmentName = parseResult.GetValueForOption(_githubEnvironmentNameOption); + return options; + } + + [McpServerTool( + Destructive = false, + ReadOnly = false, + Title = CommandTitle)] + public override Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + var options = BindOptions(parseResult); + + try + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return Task.FromResult(context.Response); + } + context.Activity?.WithSubscriptionTag(options); + var result = PipelineGenerationUtil.GeneratePipelineGuidelines(options); + + context.Response.Message = result; + context.Response.Status = 200; + } + catch (Exception ex) + { + HandleException(context, ex); + } + return Task.FromResult(context.Response); + } + +} diff --git a/src/Areas/Deploy/Commands/PlanGetCommand.cs b/src/Areas/Deploy/Commands/PlanGetCommand.cs new file mode 100644 index 000000000..00c09261c --- /dev/null +++ b/src/Areas/Deploy/Commands/PlanGetCommand.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +using AzureMcp.Areas.Deploy.Options; +using AzureMcp.Areas.Deploy.Services.Util; +using AzureMcp.Commands; +using Microsoft.Extensions.Logging; + +namespace AzureMcp.Areas.Deploy.Commands.Plan; + +public sealed class PlanGetCommand(ILogger logger) + : BaseCommand() +{ + private const string CommandTitle = "Generate Azure Deployment Plan"; + private readonly ILogger _logger = logger; + + private readonly Option _workspaceFolderOption = DeployOptionDefinitions.PlanGet.WorkspaceFolder; + private readonly Option _projectNameOption = DeployOptionDefinitions.PlanGet.ProjectName; + private readonly Option _deploymentTargetServiceOption = DeployOptionDefinitions.PlanGet.TargetAppService; + private readonly Option _provisioningToolOption = DeployOptionDefinitions.PlanGet.ProvisioningTool; + private readonly Option _azdIacOptionsOption = DeployOptionDefinitions.PlanGet.AzdIacOptions; + + public override string Name => "plan-get"; + + public override string Description => + """ + Entry point to help the agent deploy a service to the cloud. Agent should read its output and generate a deploy plan in '.azure/plan.copilotmd' for execution steps, recommended azure services based on the information agent detected from project. Before calling this tool, please scan this workspace to detect the services to deploy and their dependent services. + """; + + public override string Title => CommandTitle; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.AddOption(_workspaceFolderOption); + command.AddOption(_projectNameOption); + command.AddOption(_deploymentTargetServiceOption); + command.AddOption(_provisioningToolOption); + command.AddOption(_azdIacOptionsOption); + } + + private PlanGetOptions BindOptions(ParseResult parseResult) + { + return new PlanGetOptions + { + WorkspaceFolder = parseResult.GetValueForOption(_workspaceFolderOption) ?? string.Empty, + ProjectName = parseResult.GetValueForOption(_projectNameOption) ?? string.Empty, + TargetAppService = parseResult.GetValueForOption(_deploymentTargetServiceOption) ?? string.Empty, + ProvisioningTool = parseResult.GetValueForOption(_provisioningToolOption) ?? string.Empty, + AzdIacOptions = parseResult.GetValueForOption(_azdIacOptionsOption) ?? string.Empty + }; + } + + [McpServerTool( + Destructive = false, + ReadOnly = true, + Title = CommandTitle)] + public override Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + var options = BindOptions(parseResult); + + try + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return Task.FromResult(context.Response); + } + + var planTemplate = DeploymentPlanTemplateUtil.GetPlanTemplate(options.ProjectName, options.TargetAppService, options.ProvisioningTool, options.AzdIacOptions); + + context.Response.Message = planTemplate; + context.Response.Status = 200; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating deployment plan"); + HandleException(context, ex); + } + return Task.FromResult(context.Response); + + } + +} diff --git a/src/Areas/Deploy/Commands/QuotaCheckCommand.cs b/src/Areas/Deploy/Commands/QuotaCheckCommand.cs new file mode 100644 index 000000000..e8b2f0f0e --- /dev/null +++ b/src/Areas/Deploy/Commands/QuotaCheckCommand.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Areas.Deploy.Services.Util; +using AzureMcp.Areas.Deploy.Options; +using AzureMcp.Areas.Deploy.Services; +using AzureMcp.Commands; +using AzureMcp.Commands.Subscription; +using AzureMcp.Models.Command; +using AzureMcp.Services.Telemetry; +using Microsoft.Extensions.Logging; + +namespace AzureMcp.Areas.Deploy.Commands.Quota; + +public class QuotaCheckCommand(ILogger logger) : SubscriptionCommand() +{ + private const string CommandTitle = "Check Available Azure Quota for Regions"; + private readonly ILogger _logger = logger; + + private readonly Option _regionOption = DeployOptionDefinitions.QuotaCheck.Region; + private readonly Option _resourceTypesOption = DeployOptionDefinitions.QuotaCheck.ResourceTypes; + + public override string Name => "quota-check"; + + public override string Description => + """ + This tool will check the Azure quota availability for the resources that are going to be deployed. + """; + + public override string Title => CommandTitle; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.AddOption(_regionOption); + command.AddOption(_resourceTypesOption); + } + + protected override QuotaCheckOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Region = parseResult.GetValueForOption(_regionOption) ?? string.Empty; + options.ResourceTypes = parseResult.GetValueForOption(_resourceTypesOption) ?? string.Empty; + return options; + } + + [McpServerTool( + Destructive = false, + ReadOnly = true, + Title = CommandTitle)] + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + var options = BindOptions(parseResult); + + try + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + context.Activity?.WithSubscriptionTag(options); + var ResourceTypes = options.ResourceTypes.Split(',') + .Select(rt => rt.Trim()) + .Where(rt => !string.IsNullOrWhiteSpace(rt)) + .ToList(); + var deployService = context.GetService(); + Dictionary> toolResult = await deployService.GetAzureQuotaAsync( + ResourceTypes, + options.Subscription!, + options.Region); + + _logger.LogInformation("Quota check result: {ToolResult}", toolResult); + + context.Response.Results = toolResult?.Count > 0 ? + ResponseResult.Create( + new QuotaCheckCommandResult(toolResult), + DeployJsonContext.Default.QuotaCheckCommandResult) : + null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking Azure quota"); + HandleException(context, ex); + } + return context.Response; + + } + + internal record QuotaCheckCommandResult(Dictionary> QuotaInfo); + +} diff --git a/src/Areas/Deploy/Commands/RegionCheckCommand.cs b/src/Areas/Deploy/Commands/RegionCheckCommand.cs new file mode 100644 index 000000000..358761975 --- /dev/null +++ b/src/Areas/Deploy/Commands/RegionCheckCommand.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Areas.Deploy.Services.Util; +using AzureMcp.Areas.Deploy.Models; +using AzureMcp.Areas.Deploy.Options; +using AzureMcp.Areas.Deploy.Services; +using AzureMcp.Commands; +using AzureMcp.Commands.Subscription; +using AzureMcp.Models.Command; +using AzureMcp.Services.Telemetry; +using Microsoft.Extensions.Logging; + +namespace AzureMcp.Areas.Deploy.Commands.Region; + +public sealed class RegionCheckCommand(ILogger logger) : SubscriptionCommand() +{ + private const string CommandTitle = "Get Available Azure Regions"; + private readonly ILogger _logger = logger; + + private readonly Option _resourceTypesOption = DeployOptionDefinitions.RegionCheck.ResourceTypes; + private readonly Option _cognitiveServiceModelNameOption = DeployOptionDefinitions.RegionCheck.CognitiveServiceModelName; + private readonly Option _cognitiveServiceModelVersionOption = DeployOptionDefinitions.RegionCheck.CognitiveServiceModelVersion; + private readonly Option _cognitiveServiceDeploymentSkuNameOption = DeployOptionDefinitions.RegionCheck.CognitiveServiceDeploymentSkuName; + + public override string Name => "available-region-get"; + + public override string Description => + """ + Given a list of Azure resource types, this tool will return a list of regions where the resource types are available. Always get the user's subscription ID before calling this tool. + """; + + public override string Title => CommandTitle; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.AddOption(_resourceTypesOption); + command.AddOption(_cognitiveServiceModelNameOption); + command.AddOption(_cognitiveServiceModelVersionOption); + command.AddOption(_cognitiveServiceDeploymentSkuNameOption); + } + + protected override RegionCheckOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.ResourceTypes = parseResult.GetValueForOption(_resourceTypesOption) ?? string.Empty; + options.CognitiveServiceModelName = parseResult.GetValueForOption(_cognitiveServiceModelNameOption); + options.CognitiveServiceModelVersion = parseResult.GetValueForOption(_cognitiveServiceModelVersionOption); + options.CognitiveServiceDeploymentSkuName = parseResult.GetValueForOption(_cognitiveServiceDeploymentSkuNameOption); + return options; + } + + [McpServerTool( + Destructive = false, + ReadOnly = true, + Title = CommandTitle)] + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + var options = BindOptions(parseResult); + + try + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + context.Activity?.WithSubscriptionTag(options); + + var resourceTypes = options.ResourceTypes.Split(',') + .Select(rt => rt.Trim()) + .Where(rt => !string.IsNullOrWhiteSpace(rt)) + .ToArray(); + + if (resourceTypes.Length == 0) + { + throw new ArgumentException("Resource types cannot be empty.", nameof(options.ResourceTypes)); + } + + var deployService = context.GetService(); + List toolResult = await deployService.GetAvailableRegionsForResourceTypesAsync( + resourceTypes, + options.Subscription!, + options.CognitiveServiceModelName, + options.CognitiveServiceModelVersion, + options.CognitiveServiceDeploymentSkuName); + + _logger.LogInformation("Region check result: {ToolResult}", toolResult); + + context.Response.Results = toolResult?.Count > 0 ? + ResponseResult.Create( + new RegionCheckCommandResult(toolResult), + DeployJsonContext.Default.RegionCheckCommandResult) : + null; + } + catch (Exception ex) + { + _logger.LogError(ex, "An exception occurred checking available Azure regions."); + HandleException(context, ex); + } + + return context.Response; + } + + internal record RegionCheckCommandResult(List AvailableRegions); +} diff --git a/src/Areas/Deploy/DeploySetup.cs b/src/Areas/Deploy/DeploySetup.cs new file mode 100644 index 000000000..c785ab7c1 --- /dev/null +++ b/src/Areas/Deploy/DeploySetup.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AzureMcp.Areas.Extension.Commands; +using AzureMcp.Commands; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using AzureMcp.Areas.Deploy.Commands; +using AzureMcp.Areas.Deploy.Commands.Region; +using AzureMcp.Areas.Deploy.Commands.Plan; +using AzureMcp.Areas.Deploy.Services; +using AzureMcp.Areas.Deploy.Commands.Quota; +using AzureMcp.Areas.Deploy.Commands.InfraCodeRules; + +namespace AzureMcp.Areas.Deploy; + +internal sealed class DeploySetup : IAreaSetup +{ + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + } + + public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactory) + { + var deploy = new CommandGroup("deploy", "Deploy commands for deploying applications to Azure"); + rootGroup.AddSubGroup(deploy); + + deploy.AddCommand("plan-get", new PlanGetCommand(loggerFactory.CreateLogger())); + + deploy.AddCommand("infra-code-rules-get", new InfraCodeRulesGetCommand(loggerFactory.CreateLogger())); + + deploy.AddCommand("available-region-get", new RegionCheckCommand(loggerFactory.CreateLogger())); + + deploy.AddCommand("quota-check", new QuotaCheckCommand(loggerFactory.CreateLogger())); + + deploy.AddCommand("azd-app-log-get", new AzdAppLogGetCommand(loggerFactory.CreateLogger())); + + deploy.AddCommand("cicd-pipeline-guidance-get", new PipelineGenerateCommand(loggerFactory.CreateLogger())); + + deploy.AddCommand("architecture-diagram-generate", new GenerateArchitectureDiagramCommand(loggerFactory.CreateLogger())); + } +} diff --git a/src/Areas/Deploy/Models/AzureRegionCheckParameters.cs b/src/Areas/Deploy/Models/AzureRegionCheckParameters.cs new file mode 100644 index 000000000..fcdf57e0f --- /dev/null +++ b/src/Areas/Deploy/Models/AzureRegionCheckParameters.cs @@ -0,0 +1,10 @@ +namespace AzureMcp.Areas.Deploy.Models; + +public class CognitiveServiceProperties +{ + public string? ModelName { get; set; } = string.Empty; + + public string? ModelVersion { get; set; } = string.Empty; + + public string? DeploymentSkuName { get; set; } = string.Empty; +} diff --git a/src/Areas/Deploy/Models/InfraCodeRulesParameters.cs b/src/Areas/Deploy/Models/InfraCodeRulesParameters.cs new file mode 100644 index 000000000..c607b039a --- /dev/null +++ b/src/Areas/Deploy/Models/InfraCodeRulesParameters.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Nodes; + +namespace AzureMcp.Areas.Deploy.Models; + + +public static class DeploymentTool +{ + public const string Azd = "AZD"; + public const string AzCli = "AzCli"; +} + +public static class IacType +{ + public const string Bicep = "bicep"; + public const string Terraform = "terraform"; +} + +public static class AzureServiceNames +{ + public const string AzureContainerApp = "containerapp"; + public const string AzureAppService = "appservice"; + public const string AzureFunctionApp = "function"; +} + diff --git a/src/Areas/Deploy/Options/AppTopology.cs b/src/Areas/Deploy/Options/AppTopology.cs new file mode 100644 index 000000000..ba568ead7 --- /dev/null +++ b/src/Areas/Deploy/Options/AppTopology.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using AzureMcp.Options; + +namespace AzureMcp.Areas.Deploy.Options; + +public class AppTopology +{ + [JsonPropertyName("workspaceFolder")] + public string? WorkspaceFolder { get; set; } + + [JsonPropertyName("projectName")] + public string? ProjectName { get; set; } + + [JsonPropertyName("services")] + public ServiceConfig[] Services { get; set; } = []; +} + +public class ServiceConfig +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("path")] + public string Path { get; set; } = ""; + + [JsonPropertyName("language")] + public string Language { get; set; } = ""; + + [JsonPropertyName("port")] + public string Port { get; set; } = ""; + + [JsonPropertyName("azureComputeHost")] + public string AzureComputeHost { get; set; } = ""; + + [JsonPropertyName("dependencies")] + public DependencyConfig[] Dependencies { get; set; } = []; + + [JsonPropertyName("settings")] + public string[] Settings { get; set; } = []; + + [JsonPropertyName("dockerSettings")] + public DockerSettings? DockerSettings { get; set; } +} + +public class DockerSettings +{ + [JsonPropertyName("dockerFilePath")] + public string DockerFilePath { get; set; } = ""; + + [JsonPropertyName("dockerContext")] + public string DockerContext { get; set; } = ""; +} + +public class DependencyConfig +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("serviceType")] + public string ServiceType { get; set; } = ""; + + [JsonPropertyName("connectionType")] + public string ConnectionType { get; set; } = ""; + + [JsonPropertyName("environmentVariables")] + public string[] EnvironmentVariables { get; set; } = []; +} \ No newline at end of file diff --git a/src/Areas/Deploy/Options/DeployAppLogOptions.cs b/src/Areas/Deploy/Options/DeployAppLogOptions.cs new file mode 100644 index 000000000..2d1700f69 --- /dev/null +++ b/src/Areas/Deploy/Options/DeployAppLogOptions.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using AzureMcp.Options; + +namespace AzureMcp.Areas.Deploy.Options; + +public class AzdAppLogOptions : SubscriptionOptions +{ + [JsonPropertyName("workspaceFolder")] + public string WorkspaceFolder { get; set; } = string.Empty; + + [JsonPropertyName("azdEnvName")] + public string AzdEnvName { get; set; } = string.Empty; + + [JsonPropertyName("limit")] + public int? Limit { get; set; } +} diff --git a/src/Areas/Deploy/Options/DeployOptionDefinitions.cs b/src/Areas/Deploy/Options/DeployOptionDefinitions.cs new file mode 100644 index 000000000..9e95250ed --- /dev/null +++ b/src/Areas/Deploy/Options/DeployOptionDefinitions.cs @@ -0,0 +1,365 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using AzureMcp.Areas.Server.Commands; +using AzureMcp.Areas.Server.Commands.ToolLoading; +using AzureMcp.Options; + +namespace AzureMcp.Areas.Deploy.Options; + +public static class DeployOptionDefinitions +{ + public static class RawMcpToolInput + { + public const string RawMcpToolInputName = CommandFactoryToolLoader.RawMcpToolInputOptionName; + + public static readonly Option RawMcpToolInputOption = new( + $"--{RawMcpToolInputName}", + AppTopologySchema.Schema.ToJsonString() + ) + { + IsRequired = true + }; + } + + public class AzdAppLogOptions : SubscriptionOptions + { + public const string WorkspaceFolderName = "workspace-folder"; + public const string AzdEnvNameName = "azd-env-name"; + public const string LimitName = "limit"; + + public static readonly Option WorkspaceFolder = new( + $"--{WorkspaceFolderName}", + "The full path of the workspace folder." + ) + { + IsRequired = true + }; + + public static readonly Option AzdEnvName = new( + $"--{AzdEnvNameName}", + "The name of the environment created by azd (AZURE_ENV_NAME) during `azd init` or `azd up`. If not provided in context, try to find it in the .azure directory in the workspace or use 'azd env list'." + ) + { + IsRequired = true + }; + + public static readonly Option Limit = new( + $"--{LimitName}", + () => 200, + "The maximum row number of logs to retrieve. Use this to get a specific number of logs or to avoid the retrieved logs from reaching token limit. Default is 200." + ) + { + IsRequired = false + }; + } + + public class PipelineGenerateOptions : SubscriptionOptions + { + public const string UseAZDPipelineConfigName = "use-azd-pipeline-config"; + public const string OrganizationNameName = "organization-name"; + public const string RepositoryNameName = "repository-name"; + public const string GithubEnvironmentNameName = "github-environment-name"; + + public static readonly Option UseAZDPipelineConfig = new( + $"--{UseAZDPipelineConfigName}", + () => false, + "Whether to use azd tool to set up the deployment pipeline. Set to true ONLY if azure.yaml is provided or the context suggests AZD tools." + ) + { + IsRequired = false + }; + + public static readonly Option OrganizationName = new( + $"--{OrganizationNameName}", + "The name of the organization or the user account name of the current Github repository. DO NOT fill this in if you're not sure." + ) + { + IsRequired = false + }; + + public static readonly Option RepositoryName = new( + $"--{RepositoryNameName}", + "The name of the current Github repository. DO NOT fill this in if you're not sure." + ) + { + IsRequired = false + }; + + public static readonly Option GithubEnvironmentName = new( + $"--{GithubEnvironmentNameName}", + "The name of the environment to which the deployment pipeline will be deployed. DO NOT fill this in if you're not sure." + ) + { + IsRequired = false + }; + + } + + public static class PlanGet + { + public const string WorkspaceFolderName = "workspace-folder"; + public const string ProjectNameName = "project-name"; + public const string TargetAppServiceName = "target-app-service"; + public const string ProvisioningToolName = "provisioning-tool"; + public const string AzdIacOptionsName = "azd-iac-options"; + + public static readonly Option WorkspaceFolder = new( + $"--{WorkspaceFolderName}", + "The full path of the workspace folder." + ) + { + IsRequired = true + }; + + public static readonly Option ProjectName = new( + $"--{ProjectNameName}", + "The name of the project to generate the deployment plan for. If not provided, will be inferred from the workspace." + ) + { + IsRequired = true + }; + + public static readonly Option TargetAppService = new( + $"--{TargetAppServiceName}", + "The Azure service to deploy the application. Valid values: ContainerApp, WebApp, FunctionApp, AKS. Recommend one based on user application." + ) + { + IsRequired = true + }; + + public static readonly Option ProvisioningTool = new( + $"--{ProvisioningToolName}", + "The tool to use for provisioning Azure resources. Valid values: AZD, AzCli. Use AzCli if TargetAppService is AKS." + ) + { + IsRequired = true + }; + + public static readonly Option AzdIacOptions = new( + $"--{AzdIacOptionsName}", + "The Infrastructure as Code option for azd. Valid values: bicep, terraform." + ) + { + IsRequired = false + }; + } + + public static class QuotaCheck + { + public const string RegionName = "region"; + public const string ResourceTypesName = "resource-types"; + + public static readonly Option Region = new( + $"--{RegionName}", + "The valid Azure region where the resources will be deployed. E.g. 'eastus', 'westus', etc." + ) + { + IsRequired = true + }; + + public static readonly Option ResourceTypes = new( + $"--{ResourceTypesName}", + "The valid Azure resource types that are going to be deployed(comma-separated). E.g. 'Microsoft.App/containerApps,Microsoft.Web/sites,Microsoft.CognitiveServices/accounts', etc." + ) + { + IsRequired = true, + AllowMultipleArgumentsPerToken = true + }; + } + + public static class RegionCheck + { + public const string ResourceTypesName = "resource-types"; + public const string CognitiveServiceModelNameName = "cognitive-service-model-name"; + public const string CognitiveServiceModelVersionName = "cognitive-service-model-version"; + public const string CognitiveServiceDeploymentSkuNameName = "cognitive-service-deployment-sku-name"; + + public static readonly Option ResourceTypes = new( + $"--{ResourceTypesName}", + "Comma-separated list of Azure resource types to check available regions for. The valid Azure resource types. E.g. 'Microsoft.App/containerApps, Microsoft.Web/sites, Microsoft.CognitiveServices/accounts'." + ) + { + IsRequired = true, + AllowMultipleArgumentsPerToken = true + }; + + public static readonly Option CognitiveServiceModelName = new( + $"--{CognitiveServiceModelNameName}", + "Optional model name for cognitive services. Only needed when Microsoft.CognitiveServices is included in resource types." + ) + { + IsRequired = false + }; + + public static readonly Option CognitiveServiceModelVersion = new( + $"--{CognitiveServiceModelVersionName}", + "Optional model version for cognitive services. Only needed when Microsoft.CognitiveServices is included in resource types." + ) + { + IsRequired = false + }; + + public static readonly Option CognitiveServiceDeploymentSkuName = new( + $"--{CognitiveServiceDeploymentSkuNameName}", + "Optional deployment SKU name for cognitive services. Only needed when Microsoft.CognitiveServices is included in resource types." + ) + { + IsRequired = false + }; + } + + public static class InfraCodeRules + { + public static readonly Option DeploymentTool = new( + "--deployment-tool", + "The deployment tool to use. Valid values: AZD, AzCli") + { + IsRequired = true + }; + + public static readonly Option IacType = new( + "--iac-type", + "The Infrastructure as Code type. Valid values: bicep, terraform") + { + IsRequired = true + }; + + public static readonly Option ResourceTypes = new( + "--resource-types", + "Comma-separated list of Azure resource types to generate rules for. Supported values: 'appservice' (App Service) and/or 'containerapp' (Container App) and/or 'function' (Function App). Other resources do not have special rules.") + { + IsRequired = true, + AllowMultipleArgumentsPerToken = true + }; + } +} + +public static class AppTopologySchema +{ + public static readonly JsonObject Schema = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["workspaceFolder"] = new JsonObject + { + ["type"] = "string", + ["description"] = "The full path of the workspace folder." + }, + ["projectName"] = new JsonObject + { + ["type"] = "string", + ["description"] = "The name of the project. This is used to generate the resource names." + }, + ["services"] = new JsonObject + { + ["type"] = "array", + ["description"] = "An array of service parameters.", + ["items"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["name"] = new JsonObject + { + ["type"] = "string", + ["description"] = "The name of the service." + }, + ["path"] = new JsonObject + { + ["type"] = "string", + ["description"] = "The relative path of the service main project folder" + }, + ["language"] = new JsonObject + { + ["type"] = "string", + ["description"] = "The programming language of the service." + }, + ["port"] = new JsonObject + { + ["type"] = "string", + ["description"] = "The port number the service uses. Get this from Dockerfile for container apps. If not available, default to '80'." + }, + ["azureComputeHost"] = new JsonObject + { + ["type"] = "string", + ["description"] = "The appropriate azure service that should be used to host this service. Use containerapp if the service is containerized and has a Dockerfile.", + ["enum"] = new JsonArray("appservice", "containerapp", "function", "staticwebapp") + }, + ["dockerSettings"] = new JsonObject + { + ["type"] = "object", + ["description"] = "Docker settings for the service. This is only needed if the service's azureComputeHost is containerapp.", + ["properties"] = new JsonObject + { + ["dockerFilePath"] = new JsonObject + { + ["type"] = "string", + ["description"] = "The absolute path to the Dockerfile for the service. If the service's azureComputeHost is not containerapp, leave blank." + }, + ["dockerContext"] = new JsonObject + { + ["type"] = "string", + ["description"] = "The absolute path to the Docker build context for the service. If the service's azureComputeHost is not containerapp, leave blank." + } + }, + ["required"] = new JsonArray("dockerFilePath", "dockerContext") + }, + ["dependencies"] = new JsonObject + { + ["type"] = "array", + ["description"] = "An array of dependent services. A compute service may have a dependency on another compute service.", + ["items"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["name"] = new JsonObject + { + ["type"] = "string", + ["description"] = "The name of the dependent service. Can be arbitrary, or must reference another service in the services array if referencing azureappservice, azurecontainerapp, azurestaticwebapps, or azurefunctions." + }, + ["serviceType"] = new JsonObject + { + ["type"] = "string", + ["description"] = "The name of the azure service that can be used for this dependent service.", + ["enum"] = new JsonArray("azureaisearch", "azureaiservices", "appservice", "azureapplicationinsights", "azurebotservice", "containerapp", "azurecosmosdb", "functionapp", "azurekeyvault", "azuredatabaseformysql", "azureopenai", "azuredatabaseforpostgresql", "azureprivateendpoint", "azurecacheforredis", "azuresqldatabase", "azurestorageaccount", "staticwebapp", "azureservicebus", "azuresignalrservice", "azurevirtualnetwork", "azurewebpubsub") + }, + ["connectionType"] = new JsonObject + { + ["type"] = "string", + ["description"] = "The connection authentication type of the dependency.", + ["enum"] = new JsonArray("http", "secret", "system-identity", "user-identity", "bot-connection") + }, + ["environmentVariables"] = new JsonObject + { + ["type"] = "array", + ["description"] = "An array of environment variables defined in source code to set up the connection.", + ["items"] = new JsonObject + { + ["type"] = "string" + } + } + }, + ["required"] = new JsonArray("name", "serviceType", "connectionType", "environmentVariables") + } + }, + ["settings"] = new JsonObject + { + ["type"] = "array", + ["description"] = "An array of environment variables needed to run this service. Please search the entire codebase to find environment variables.", + ["items"] = new JsonObject + { + ["type"] = "string" + } + } + }, + ["required"] = new JsonArray("name", "path", "azureComputeHost", "language", "port", "dependencies", "settings") + } + } + }, + ["required"] = new JsonArray("workspaceFolder", "services") + }; +} diff --git a/src/Areas/Deploy/Options/InfraCodeRulesOptions.cs b/src/Areas/Deploy/Options/InfraCodeRulesOptions.cs new file mode 100644 index 000000000..e815f4900 --- /dev/null +++ b/src/Areas/Deploy/Options/InfraCodeRulesOptions.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AzureMcp.Areas.Deploy.Options; + +public sealed class InfraCodeRulesOptions +{ + public string DeploymentTool { get; set; } = string.Empty; + public string IacType { get; set; } = string.Empty; + public string ResourceTypes { get; set; } = string.Empty; +} diff --git a/src/Areas/Deploy/Options/PipelineGenerateOptions.cs b/src/Areas/Deploy/Options/PipelineGenerateOptions.cs new file mode 100644 index 000000000..35b8ed9d4 --- /dev/null +++ b/src/Areas/Deploy/Options/PipelineGenerateOptions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using AzureMcp.Options; + +namespace AzureMcp.Areas.Deploy.Options; + +public class PipelineGenerateOptions : SubscriptionOptions +{ + [JsonPropertyName("useAZDPipelineConfig")] + public bool UseAZDPipelineConfig { get; set; } + + [JsonPropertyName("organizationName")] + public string? OrganizationName { get; set; } + + [JsonPropertyName("repositoryName")] + public string? RepositoryName { get; set; } + + [JsonPropertyName("githubEnvironmentName")] + public string? GithubEnvironmentName { get; set; } +} diff --git a/src/Areas/Deploy/Options/PlanGetOptions.cs b/src/Areas/Deploy/Options/PlanGetOptions.cs new file mode 100644 index 000000000..cc075e125 --- /dev/null +++ b/src/Areas/Deploy/Options/PlanGetOptions.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace AzureMcp.Areas.Deploy.Options; + +public sealed class PlanGetOptions +{ + [JsonPropertyName("workspaceFolder")] + public string WorkspaceFolder { get; set; } = string.Empty; + + [JsonPropertyName("projectName")] + public string ProjectName { get; set; } = string.Empty; + + [JsonPropertyName("targetAppService")] + public string TargetAppService { get; set; } = string.Empty; + + [JsonPropertyName("provisioningTool")] + public string ProvisioningTool { get; set; } = string.Empty; + + [JsonPropertyName("azdIacOptions")] + public string? AzdIacOptions { get; set; } = string.Empty; +} diff --git a/src/Areas/Deploy/Options/QuotaCheckOptions.cs b/src/Areas/Deploy/Options/QuotaCheckOptions.cs new file mode 100644 index 000000000..ec012c0f1 --- /dev/null +++ b/src/Areas/Deploy/Options/QuotaCheckOptions.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using AzureMcp.Options; + +namespace AzureMcp.Areas.Deploy.Options; + +public sealed class QuotaCheckOptions : SubscriptionOptions +{ + [JsonPropertyName("region")] + public string Region { get; set; } = string.Empty; + + [JsonPropertyName("resourceTypes")] + public string ResourceTypes { get; set; } = string.Empty; +} diff --git a/src/Areas/Deploy/Options/RawMcpToolInputOptions.cs b/src/Areas/Deploy/Options/RawMcpToolInputOptions.cs new file mode 100644 index 000000000..c3f129b24 --- /dev/null +++ b/src/Areas/Deploy/Options/RawMcpToolInputOptions.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using AzureMcp.Areas.Server.Commands; +using AzureMcp.Areas.Server.Commands.ToolLoading; +using AzureMcp.Options; + +namespace AzureMcp.Areas.Deploy.Options; + +public class RawMcpToolInputOptions : GlobalOptions +{ + [JsonPropertyName(CommandFactoryToolLoader.RawMcpToolInputOptionName)] + public string? RawMcpToolInput { get; set; } +} \ No newline at end of file diff --git a/src/Areas/Deploy/Options/RegionCheckOptions.cs b/src/Areas/Deploy/Options/RegionCheckOptions.cs new file mode 100644 index 000000000..b1ee4e799 --- /dev/null +++ b/src/Areas/Deploy/Options/RegionCheckOptions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using AzureMcp.Options; + +namespace AzureMcp.Areas.Deploy.Options; + +public sealed class RegionCheckOptions : SubscriptionOptions +{ + [JsonPropertyName("resourceTypes")] + public string ResourceTypes { get; set; } = string.Empty; + + [JsonPropertyName("modelName")] + public string? CognitiveServiceModelName { get; set; } + + [JsonPropertyName("modelVersion")] + public string? CognitiveServiceModelVersion { get; set; } + + [JsonPropertyName("deploymentSkuName")] + public string? CognitiveServiceDeploymentSkuName { get; set; } +} diff --git a/src/Areas/Deploy/Services/DeployService.cs b/src/Areas/Deploy/Services/DeployService.cs new file mode 100644 index 000000000..7e58e53da --- /dev/null +++ b/src/Areas/Deploy/Services/DeployService.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using Areas.Deploy.Services.Util; +using Azure; +using Azure.Core; +using Azure.Identity; +using Azure.ResourceManager; +using Azure.ResourceManager.Resources; +using AzureMcp.Areas.Deploy.Models; +using AzureMcp.Helpers; +using AzureMcp.Options; +using AzureMcp.Services.Azure; +using AzureMcp.Services.Azure.Subscription; +using Microsoft.Extensions.Logging; + +namespace AzureMcp.Areas.Deploy.Services; + +public class DeployService() : BaseAzureService, IDeployService +{ + + [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "")] + public async Task GetAzdResourceLogsAsync( + string workspaceFolder, + string azdEnvName, + string subscriptionId, + int? limit = null) + { + TokenCredential credential = await GetCredential(); + string result = await AzdResourceLogService.GetAzdResourceLogsAsync( + credential, + workspaceFolder, + azdEnvName, + subscriptionId, + limit); + return result; + } + + public async Task>> GetAzureQuotaAsync( + List resourceTypes, + string subscriptionId, + string location) + { + TokenCredential credential = await GetCredential(); + Dictionary> quotaByResourceTypes = await AzureQuotaService.GetAzureQuotaAsync( + credential, + resourceTypes, + subscriptionId, + location + ); + return quotaByResourceTypes; + } + + public async Task> GetAvailableRegionsForResourceTypesAsync( + string[] resourceTypes, + string subscriptionId, + string? cognitiveServiceModelName = null, + string? cognitiveServiceModelVersion = null, + string? cognitiveServiceDeploymentSkuName = null) + { + ArmClient armClient = await CreateArmClientAsync(); + + // Create cognitive service properties if any of the parameters are provided + CognitiveServiceProperties? cognitiveServiceProperties = null; + if (!string.IsNullOrWhiteSpace(cognitiveServiceModelName) || + !string.IsNullOrWhiteSpace(cognitiveServiceModelVersion) || + !string.IsNullOrWhiteSpace(cognitiveServiceDeploymentSkuName)) + { + cognitiveServiceProperties = new CognitiveServiceProperties + { + ModelName = cognitiveServiceModelName, + ModelVersion = cognitiveServiceModelVersion, + DeploymentSkuName = cognitiveServiceDeploymentSkuName + }; + } + + var availableRegions = await AzureRegionService.GetAvailableRegionsForResourceTypesAsync(armClient, resourceTypes, subscriptionId, cognitiveServiceProperties); + var allRegions = availableRegions.Values + .Where(regions => regions.Count > 0) + .SelectMany(regions => regions) + .Distinct() + .ToList(); + + List commonValidRegions = availableRegions.Values + .Aggregate((current, next) => current.Intersect(next).ToList()); + + return commonValidRegions; + } +} diff --git a/src/Areas/Deploy/Services/IDeployService.cs b/src/Areas/Deploy/Services/IDeployService.cs new file mode 100644 index 000000000..6e33a9f2c --- /dev/null +++ b/src/Areas/Deploy/Services/IDeployService.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Areas.Deploy.Services.Util; +using AzureMcp.Areas.Deploy.Models; +using AzureMcp.Options; + +namespace AzureMcp.Areas.Deploy.Services; + +public interface IDeployService +{ + Task GetAzdResourceLogsAsync( + string workspaceFolder, + string azdEnvName, + string subscriptionId, + int? limit = null); + + Task>> GetAzureQuotaAsync( + List resourceTypes, + string subscriptionId, + string location); + + + Task> GetAvailableRegionsForResourceTypesAsync( + string[] resourceTypes, + string subscriptionId, + string? cognitiveServiceModelName = null, + string? cognitiveServiceModelVersion = null, + string? cognitiveServiceDeploymentSkuName = null); +} diff --git a/src/Areas/Deploy/Services/Util/AzdAppLogRetriever.cs b/src/Areas/Deploy/Services/Util/AzdAppLogRetriever.cs new file mode 100644 index 000000000..10e9103f8 --- /dev/null +++ b/src/Areas/Deploy/Services/Util/AzdAppLogRetriever.cs @@ -0,0 +1,239 @@ +using Azure.Monitor.Query; +using Azure.ResourceManager; +using Azure.ResourceManager.Resources; +using Azure.ResourceManager.AppContainers; +using Azure.ResourceManager.AppService; +using Azure.Monitor.Query.Models; +using Azure.Core; + +namespace Areas.Deploy.Services.Util; + +public class AzdAppLogRetriever(TokenCredential credential, string subscriptionId, string azdEnvName) +{ + private readonly string _subscriptionId = subscriptionId; + private readonly string _azdEnvName = azdEnvName; + private readonly Dictionary _apps = new(); + private readonly Dictionary _logs = new(); + private readonly List _logAnalyticsWorkspaceIds = new(); + private string _resourceGroupName = string.Empty; + + private ArmClient? _armClient; + private LogsQueryClient? _queryClient; + + public async Task InitializeAsync() + { + _armClient = new ArmClient(credential, _subscriptionId); + _queryClient = new LogsQueryClient(credential); + + _resourceGroupName = await GetResourceGroupNameAsync(); + if (string.IsNullOrEmpty(_resourceGroupName)) + { + throw new InvalidOperationException($"No resource group with tag {{\"azd-env-name\": {_azdEnvName}}} found."); + } + } + + public async Task GetLogAnalyticsWorkspacesInfoAsync() + { + var subscription = _armClient!.GetSubscriptionResource(new($"/subscriptions/{_subscriptionId}")); + var resourceGroup = await subscription.GetResourceGroupAsync(_resourceGroupName); + + var filter = "resourceType eq 'Microsoft.OperationalInsights/workspaces'"; + + await foreach (var resource in resourceGroup.Value.GetGenericResourcesAsync(filter: filter)) + { + _logAnalyticsWorkspaceIds.Add(resource.Id.ToString()); + } + + if (_logAnalyticsWorkspaceIds.Count == 0) + { + throw new InvalidOperationException($"No log analytics workspaces found for resource group {_resourceGroupName}. Logs cannot be retrieved using this tool."); + } + } + + public async Task RegisterAppAsync(ResourceType resourceType, string serviceName) + { + var subscription = _armClient!.GetSubscriptionResource(new($"/subscriptions/{_subscriptionId}")); + var resourceGroup = await subscription.GetResourceGroupAsync(_resourceGroupName); + + var filter = $"tagName eq 'azd-service-name' and tagValue eq '{serviceName}'"; + var apps = new List(); + + await foreach (var resource in resourceGroup.Value.GetGenericResourcesAsync(filter: filter)) + { + var resourceTypeString = resourceType.GetResourceTypeString(); + var parts = resourceTypeString.Split('|'); + var type = parts[0]; + var kind = parts.Length > 1 ? parts[1] : null; + + if (resource.Data.ResourceType.ToString() == type && + (kind == null || resource.Data.Kind?.StartsWith(kind) == true)) + { + _logs[resource.Id.ToString()] = string.Empty; + apps.Add(resource); + } + } + + return apps.Count switch + { + 0 => throw new InvalidOperationException($"No resources found for resource type {resourceType} with tag azd-service-name={serviceName}"), + > 1 => throw new InvalidOperationException($"Multiple resources found for resource type {resourceType} with tag azd-service-name={serviceName}"), + _ => apps[0] + }; + } + + private static string GetContainerAppLogsQuery(string containerAppName, int limit) => + $"ContainerAppConsoleLogs_CL | where ContainerAppName_s == '{containerAppName}' | order by _timestamp_d desc | project TimeGenerated, Log_s | take {limit}"; + + private static string GetAppServiceLogsQuery(string appServiceResourceId, int limit) => + $"AppServiceConsoleLogs | where _ResourceId == '{appServiceResourceId.ToLowerInvariant()}' | order by TimeGenerated desc | project TimeGenerated, ResultDescription | take {limit}"; + + private static string GetFunctionAppLogsQuery(string functionAppName, int limit) => + $"AppTraces | where AppRoleName == '{functionAppName}' | order by TimeGenerated desc | project TimeGenerated, Message | take {limit}"; + + public async Task QueryAppLogsAsync(ResourceType resourceType, string serviceName, int? limit = null) + { + var app = await RegisterAppAsync(resourceType, serviceName); + var getLogErrors = new List(); + var getLogSuccess = false; + var logSearchQuery = string.Empty; + DateTimeOffset? lastDeploymentTime = null; + + var actualLimit = limit ?? 200; + DateTimeOffset endTime = DateTime.UtcNow; + DateTimeOffset startTime = endTime.AddHours(-4); + + switch (resourceType) + { + case ResourceType.ContainerApps: + logSearchQuery = GetContainerAppLogsQuery(app.Data.Name, actualLimit); + // Get last deployment time for container apps + var containerAppResource = _armClient!.GetContainerAppResource(app.Id); + var containerApp = await containerAppResource.GetAsync(); + + await foreach (var revision in containerApp.Value.GetContainerAppRevisions()) + { + var revisionData = await revision.GetAsync(); + if (revisionData.Value.Data.IsActive == true) + { + lastDeploymentTime = revisionData.Value.Data.CreatedOn; + break; + } + } + break; + + case ResourceType.AppService: + case ResourceType.FunctionApp: + var webSiteResource = _armClient!.GetWebSiteResource(app.Id); + + await foreach (var deployment in webSiteResource.GetSiteDeployments()) + { + var deploymentData = await deployment.GetAsync(); + if (deploymentData.Value.Data.IsActive == true) + { + lastDeploymentTime = deploymentData.Value.Data.StartOn; + break; + } + } + + logSearchQuery = resourceType == ResourceType.AppService + ? GetAppServiceLogsQuery(app.Id.ToString(), actualLimit) + : GetFunctionAppLogsQuery(app.Data.Name, actualLimit); + break; + + default: + throw new ArgumentException($"Unsupported resource type: {resourceType}"); + } + + // startTime is now, endTime is 1 hour ago + + if (lastDeploymentTime.HasValue && lastDeploymentTime > startTime) + { + startTime = lastDeploymentTime ?? startTime; + } + + foreach (var logAnalyticsId in _logAnalyticsWorkspaceIds) + { + try + { + var timeRange = new QueryTimeRange(startTime, endTime); + var response = await _queryClient!.QueryResourceAsync(new(logAnalyticsId), logSearchQuery, timeRange); + + if (response.Value.Status == LogsQueryResultStatus.Success) + { + foreach (var table in response.Value.AllTables) + { + foreach (var row in table.Rows) + { + _logs[app.Id.ToString()] += $"[{row[0]}] {row[1]}\n"; + } + } + getLogSuccess = true; + break; + } + } + catch (Exception ex) + { + getLogErrors.Add($"Error retrieving logs for {app.Data.Name} from {logAnalyticsId}: {ex.Message}"); + } + } + + if (!getLogSuccess) + { + throw new InvalidOperationException($"Errors: {string.Join(", ", getLogErrors)}"); + } + + return $"Console Logs for {serviceName} with resource ID {app.Id} between {startTime} and {endTime}:\n{_logs[app.Id.ToString()]}"; + } + + private async Task GetResourceGroupNameAsync() + { + var subscription = _armClient!.GetSubscriptionResource(new($"/subscriptions/{_subscriptionId}")); + + await foreach (var resourceGroup in subscription.GetResourceGroups()) + { + if (resourceGroup.Data.Tags.TryGetValue("azd-env-name", out var envName) && envName == _azdEnvName) + { + return resourceGroup.Data.Name; + } + } + + return string.Empty; + } + +} + +public enum ResourceType +{ + AppService, + ContainerApps, + FunctionApp +} + +public static class ResourceTypeExtensions +{ + private static readonly Dictionary HostToResourceType = new() + { + { "containerapp", ResourceType.ContainerApps }, + { "appservice", ResourceType.AppService }, + { "function", ResourceType.FunctionApp } + }; + + private static readonly Dictionary ResourceTypeToString = new() + { + { ResourceType.AppService, "Microsoft.Web/sites|app" }, + { ResourceType.ContainerApps, "Microsoft.App/containerApps" }, + { ResourceType.FunctionApp, "Microsoft.Web/sites|functionapp" } + }; + + public static ResourceType GetResourceTypeFromHost(string host) + { + return HostToResourceType.TryGetValue(host, out var resourceType) + ? resourceType + : throw new ArgumentException($"Unknown host type: {host}"); + } + + public static string GetResourceTypeString(this ResourceType resourceType) + { + return ResourceTypeToString[resourceType]; + } +} diff --git a/src/Areas/Deploy/Services/Util/AzdResourceLogService.cs b/src/Areas/Deploy/Services/Util/AzdResourceLogService.cs new file mode 100644 index 000000000..af3ed12f4 --- /dev/null +++ b/src/Areas/Deploy/Services/Util/AzdResourceLogService.cs @@ -0,0 +1,108 @@ +using System.Diagnostics.CodeAnalysis; +using Azure.Core; +using YamlDotNet.Serialization; + +namespace Areas.Deploy.Services.Util; + +public static class AzdResourceLogService +{ + private const string AzureYamlFileName = "azure.yaml"; + + [RequiresDynamicCode("Uses YamlDotNet for deserialization.")] + public static async Task GetAzdResourceLogsAsync( + TokenCredential credential, + string workspaceFolder, + string azdEnvName, + string subscriptionId, + int? limit = null) + { + var toolErrorLogs = new List(); + var appLogs = new List(); + + try + { + var azdAppLogRetriever = new AzdAppLogRetriever(credential, subscriptionId, azdEnvName); + await azdAppLogRetriever.InitializeAsync(); + await azdAppLogRetriever.GetLogAnalyticsWorkspacesInfoAsync(); + + var services = GetServicesFromAzureYaml(workspaceFolder); + + foreach (var (serviceName, service) in services) + { + try + { + if (service.Host != null) + { + var resourceType = ResourceTypeExtensions.GetResourceTypeFromHost(service.Host); + var logs = await azdAppLogRetriever.QueryAppLogsAsync(resourceType, serviceName, limit); + appLogs.Add(logs); + } + } + catch (Exception ex) + { + toolErrorLogs.Add($"Error finding app logs for service {serviceName}: {ex.Message}"); + } + } + } + catch (Exception ex) + { + toolErrorLogs.Add(ex.Message); + } + + if (appLogs.Count > 0) + { + return $"App logs retrieved:\n{string.Join("\n\n", appLogs)}"; + } + + if (toolErrorLogs.Count > 0) + { + return $"Error during retrieval of app logs of azd project:\n{string.Join("\n", toolErrorLogs)}"; + } + + return "No logs found."; + } + + [RequiresDynamicCode("Calls YamlDotNet.Serialization.DeserializerBuilder.DeserializerBuilder()")] + private static Dictionary GetServicesFromAzureYaml(string workspaceFolder) + { + var azureYamlPath = Path.Combine(workspaceFolder, AzureYamlFileName); + + if (!File.Exists(azureYamlPath)) + { + throw new FileNotFoundException($"Azure YAML file not found at {azureYamlPath}"); + } + + var yamlContent = File.ReadAllText(azureYamlPath); + var deserializer = new DeserializerBuilder().Build(); + var azureYaml = deserializer.Deserialize>(yamlContent); + + if (!azureYaml.TryGetValue("services", out var servicesObj)) + { + throw new InvalidOperationException("No services section found in azure.yaml"); + } + + var servicesDict = (Dictionary)servicesObj; + var result = new Dictionary(); + + foreach (var (key, value) in servicesDict) + { + var serviceName = key.ToString()!; + var serviceDict = (Dictionary)value; + + var service = new Service( + Host: serviceDict.TryGetValue("host", out var host) ? host?.ToString() : null, + Project: serviceDict.TryGetValue("project", out var project) ? project?.ToString() : null, + Language: serviceDict.TryGetValue("language", out var language) ? language?.ToString() : null + ); + + result[serviceName] = service; + } + + return result; + } +} +public record Service( + string? Host = null, + string? Project = null, + string? Language = null +); diff --git a/src/Areas/Deploy/Services/Util/AzureRegionChecker.cs b/src/Areas/Deploy/Services/Util/AzureRegionChecker.cs new file mode 100644 index 000000000..67516749a --- /dev/null +++ b/src/Areas/Deploy/Services/Util/AzureRegionChecker.cs @@ -0,0 +1,225 @@ +using Azure; +using Azure.Core; +using Azure.ResourceManager; +using Azure.ResourceManager.CognitiveServices; +using Azure.ResourceManager.CognitiveServices.Models; +using Azure.ResourceManager.PostgreSql.FlexibleServers; +using Azure.ResourceManager.PostgreSql.FlexibleServers.Models; +using AzureMcp.Areas.Deploy.Models; + +namespace Areas.Deploy.Services.Util; + +public interface IRegionChecker +{ + Task> GetAvailableRegionsAsync(string resourceType); +} + +public abstract class AzureRegionChecker : IRegionChecker +{ + protected readonly string SubscriptionId; + protected readonly ArmClient ResourceClient; + protected AzureRegionChecker(ArmClient armClient, string subscriptionId) + { + SubscriptionId = subscriptionId; + ResourceClient = armClient; + Console.WriteLine($"AzureRegionChecker initialized for subscription: {subscriptionId}"); + } + public abstract Task> GetAvailableRegionsAsync(string resourceType); +} + +public class DefaultRegionChecker(ArmClient armClient, string subscriptionId) : AzureRegionChecker(armClient, subscriptionId) +{ + public override async Task> GetAvailableRegionsAsync(string resourceType) + { + try + { + var parts = resourceType.Split('/'); + var providerNamespace = parts[0]; + var resourceTypeName = parts[1]; + + var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); + var provider = await subscription.GetResourceProviderAsync(providerNamespace); + + if (provider?.Value?.Data?.ResourceTypes == null) + { + return []; + } + + var resourceTypeInfo = provider.Value.Data.ResourceTypes + .FirstOrDefault(rt => rt.ResourceType.Equals(resourceTypeName, StringComparison.OrdinalIgnoreCase)); + + if (resourceTypeInfo?.Locations == null) + { + return []; + } + + return resourceTypeInfo.Locations + .Select(location => location.Replace(" ", "").ToLowerInvariant()) + .ToList(); + } + catch (Exception error) + { + Console.WriteLine($"Error fetching regions for resource type {resourceType}: {error.Message}"); + return []; + } + } +} + +public class CognitiveServicesRegionChecker : AzureRegionChecker +{ + private readonly string? _skuName; + private readonly string? _apiVersion; + private readonly string? _modelName; + + public CognitiveServicesRegionChecker(ArmClient armClient, string subscriptionId, string? skuName = null, string? apiVersion = null, string? modelName = null) + : base(armClient, subscriptionId) + { + _skuName = skuName; + _apiVersion = apiVersion; + _modelName = modelName; + } + + public override async Task> GetAvailableRegionsAsync(string resourceType) + { + var parts = resourceType.Split('/'); + var providerNamespace = parts[0]; + var resourceTypeName = parts[1]; + + var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); + var provider = await subscription.GetResourceProviderAsync(providerNamespace); + + List regions = provider?.Value?.Data?.ResourceTypes? + .FirstOrDefault(rt => rt.ResourceType.Equals(resourceTypeName, StringComparison.OrdinalIgnoreCase)) + ?.Locations? + .Select(location => location.Replace(" ", "").ToLowerInvariant()) + .ToList() ?? new List(); + + var availableRegions = new List(); + + foreach (var region in regions) + { + try + { + var quotas = subscription.GetModels(region); + + bool hasMatchingModel = false; + + foreach (CognitiveServicesModel modelElement in quotas) + { + var nameMatch = string.IsNullOrEmpty(_modelName) || + (modelElement.Model?.Name == _modelName); + + var versionMatch = string.IsNullOrEmpty(_apiVersion) || + (modelElement.Model?.Version == _apiVersion); + + + var skuMatch = string.IsNullOrEmpty(_skuName) || + (modelElement.Model?.Skus?.Any(sku => sku.Name == _skuName) ?? false); + + if (nameMatch && versionMatch && skuMatch) + { + hasMatchingModel = true; + break; + } + } + + if (hasMatchingModel) + { + availableRegions.Add(region); + } + } + catch (Exception error) + { + Console.WriteLine($"Error checking cognitive services models for region {region}: {error.Message}"); + } + } + + return availableRegions; + } +} + +public class PostgreSqlRegionChecker(ArmClient armClient, string subscriptionId) : AzureRegionChecker(armClient, subscriptionId) +{ + public override async Task> GetAvailableRegionsAsync(string resourceType) + { + var parts = resourceType.Split('/'); + var providerNamespace = parts[0]; + var resourceTypeName = parts[1]; + + var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); + var provider = await subscription.GetResourceProviderAsync(providerNamespace); + var regions = provider?.Value?.Data?.ResourceTypes? + .FirstOrDefault(rt => rt.ResourceType.Equals(resourceTypeName, StringComparison.OrdinalIgnoreCase)) + ?.Locations? + .Select(location => location.Replace(" ", "").ToLowerInvariant()) + .ToList() ?? new List(); + + var availableRegions = new List(); + + foreach (var region in regions) + { + try + { + Pageable result = subscription.ExecuteLocationBasedCapabilities(region); + foreach (var capability in result) + { + if (capability.SupportedServerEditions?.Any() == true) + { + availableRegions.Add(region); + break; // No need to check further capabilities for this region + } + } + } + catch (Exception error) + { + Console.WriteLine($"Error checking PostgreSQL capabilities for region {region}: {error.Message}"); + } + } + + return availableRegions; + } +} + +public static class RegionCheckerFactory +{ + public static IRegionChecker CreateRegionChecker( + ArmClient armClient, + string subscriptionId, + string resourceType, + CognitiveServiceProperties? properties = null) + { + var provider = resourceType.Split('/')[0].ToLowerInvariant(); + + return provider switch + { + "microsoft.cognitiveservices" => new CognitiveServicesRegionChecker( + armClient, + subscriptionId, + properties?.DeploymentSkuName, + properties?.ModelVersion, + properties?.ModelName), + "microsoft.dbforpostgresql" => new PostgreSqlRegionChecker(armClient, subscriptionId), + _ => new DefaultRegionChecker(armClient, subscriptionId) + }; + } +} + +public static class AzureRegionService +{ + public static async Task>> GetAvailableRegionsForResourceTypesAsync( + ArmClient armClient, + string[] resourceTypes, + string subscriptionId, + CognitiveServiceProperties? cognitiveServiceProperties = null) + { + var result = new Dictionary>(); + + foreach (var resourceType in resourceTypes) + { + var checker = RegionCheckerFactory.CreateRegionChecker(armClient, subscriptionId, resourceType, cognitiveServiceProperties); + result[resourceType] = await checker.GetAvailableRegionsAsync(resourceType); + } + + return result; + } +} diff --git a/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs b/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs new file mode 100644 index 000000000..c87fa46b1 --- /dev/null +++ b/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AzureMcp.Areas.Deploy.Models; + +namespace AzureMcp.Areas.Deploy.Services.Util; + +/// +/// Utility class for generating deployment plan templates. +/// +public static class DeploymentPlanTemplateUtil +{ + /// + /// Generates a deployment plan template with the specified project name. + /// + /// The name of the project. Can be null or empty. + /// A formatted deployment plan template string. + public static string GetPlanTemplate(string projectName, string targetAppService, string provisioningTool, string? azdIacOptions = "") + { + // Default values for optional parameters + if (provisioningTool == "azd" && string.IsNullOrWhiteSpace(azdIacOptions)) + { + azdIacOptions = "bicep"; + } + var azureComputeHost = targetAppService.ToLowerInvariant() switch + { + "containerapp" => "Azure Container Apps", + "webapp" => "Azure Web App Service", + "functionapp" => "Azure Functions", + "aks" => "Azure Kubernetes Service", + _ => "Azure Container Apps" + }; + + var aksDeploySteps = """ + 2. Build and Deploy the Application + 1. Build and Push Docker Image: {Agent should check if Dockerfile exists, if not add the step: "generate a Dockerfile for the application deployment", if does, list the Dockerfile path}. + 2. Prepare Kubernetes Manifests: {Agent should check if Kubernetes YAML files exists, if not add the step: "generate for the application deployment", if does, list the yaml files path}. + 3. Deploy to AKS: Use `kubectl apply` to deploy manifests to the AKS cluster + 3. Validation: + 1. Verify pods are running and services are exposed + """; + + var summary = "Summarize the deployment result and save to '.azure/summary.copilotmd'. It should list all changes deployment files and brief description of each file. Then have a diagram showing the provisioned azure resource."; + var steps = new List(); + + if (provisioningTool.ToLowerInvariant() == "azd") + { + steps.Add($""" + 1. Provision Azure Infrastructure + 1. Based on following required Azure resources in plan, get the infra code rules from the tool infra-code-rules-get + 2. Generate IaC ({azdIacOptions} files) for required azure resources based on the plan. + 3. Pre-check: use get_errors tool to check generated Bicep grammar errors. Fix the errors if exist. + 4. Run the AZD command `azd provision` to provision the resources and confirm each resource is created or already exists. + 5. Check the deployment output to ensure the resources are provisioned successfully. + """); + if (targetAppService.ToLowerInvariant() == "aks") + { + steps.Add(aksDeploySteps); + steps.Add($$""" + 4: Summary: + 1. {{summary}} + """); + } + else + { + steps.Add($$""" + 3: Summary: + 1. {{summary}} + """); + } + + + } + else if (provisioningTool.Equals(DeploymentTool.AzCli, StringComparison.OrdinalIgnoreCase)) + { + steps.Add(""" + 1. Provision Azure Infrastructure: + 1. Generate Azure CLI scripts for required azure resources based on the plan. + 2. Check and fix the generated Azure CLI scripts for grammar errors. + 3. Run the Azure CLI scripts to provision the resources and confirm each resource is created or already exists + """); + if (targetAppService.ToLowerInvariant() == "aks") + { + steps.Add(aksDeploySteps); + } + else + { + var isContainerApp = targetAppService.ToLowerInvariant() == "containerapp"; + var containerAppOptions = isContainerApp ? " 1. Build and Push Docker Image: Agent should check if Dockerfile exists, if not add the step: 'generate a Dockerfile for the application deployment', if it does, list the Dockerfile path" : ""; + var orderList = isContainerApp ? "2." : "1."; + steps.Add($$""" + 2. Build and Deploy the Application: + {{containerAppOptions}} + {{orderList}} Deploy to {{azureComputeHost}}: Use Azure CLI command to deploy the application + 3. Validation: + 1. Verify command output to ensure the application is deployed successfully + """); + } + steps.Add($$""" + 4: Summary: + 1 {{summary}} + """); + } + var title = string.IsNullOrWhiteSpace(projectName) + ? "Azure Deployment Plan" + : $"Azure Deployment Plan for {projectName} Project"; + + return $$""" +{Agent should fill in and polish the markdown template below to generate a deployment plan for the project. Then save it to '.azure/plan.copilotmd' file.} + +#Title: {{title}} +## **Goal** +Based on the project to provide a plan to deploy the project to Azure using AZD. It will generate Bicep files and Azure YAML configuration. + +## **Execution Step** + +{{string.Join(Environment.NewLine, steps)}} + +## **Project Summary** +{ +briefly summarize the project structure, services, and configurations, example: +- **Technology Stack**: ASP.NET Core 7.0 Razor Pages application +- **Application Type**: Task Manager web application with client-side JavaScript +- **Containerization**: Ready for deployment with existing Dockerfile +- **Dependencies**: No external dependencies detected (database, APIs, etc.) +- **Hosting Recommendation**: Azure Container Apps for scalable, serverless container hosting +} + +## **Recommended Azure Resources** + +Recommended App service hosting the project //agent should fulfill this for each app instance +- Application {{projectName}} + - Hosting Service Type: {{azureComputeHost}} // it can be Azure Container Apps, Web App Service, Azure Functions, Azure Kubernetes Service. Recommend one based on the project. + - SKU // recommend a sku based on the project, show its cost and performance + - Configuration: + - language: {language} //detect from the project, it can be nodejs, python, dotnet, etc. + - dockerFilePath: {dockerFilePath}// fulfill this if service.azureComputeHost is ContainerApp + - dockerContext: {dockerContext}// fulfill this if service.azureComputeHost is ContainerApp + - Environment Variables: [] // the env variables that are used in the project/required by service + - Dependencies Resource + - Dependency Name + - SKU // recommend a sku, show its cost and performance + - Service Type // it can be Azure SQL, Azure Cosmos DB, Azure Storage, etc. + - Connection Type // it can be connection string, managed identity, etc. + - Environment Variables: [] // the env variables that are used in the project/required by dependency + +Recommended Supporting Services +- Application Insights +- Log Analytics Workspace: set all app service to connect to this +- Key Vault(Optional): If there are dependencies such as postgresql/sql/mysql, create a Key Vault to store connection string. If not, the resource should not show. +If there is a Container App, the following resources are required: +- Container Registry +- User managed identity: Must be assigned to the container app. +- AcrPull role assignment: User managed identity must have **AcrPull** role ("7f951dda-4ed3-4680-a7ca-43fe172d538d") assigned to the container registry. +If there is a WebApp(App Service): +- App Service Site Extension (Microsoft.Web/sites/siteextensions): Required for App Service deployments. +- User managed identity: Must have **AcrPull** role ("7f951dda-4ed3-4680-a7ca-43fe172d538d") to the container registry. + +## **Azure Resources Architecture** +- **Install the mermaid extension in IDE to view the architecture.** +{a mermaid graph of the deployed resource architecture. Only keep the most important edges to make structure clear and readable.} +"""; + } +} diff --git a/src/Areas/Deploy/Services/Util/InfraCodeRuleRetriever.cs b/src/Areas/Deploy/Services/Util/InfraCodeRuleRetriever.cs new file mode 100644 index 000000000..83dca39f5 --- /dev/null +++ b/src/Areas/Deploy/Services/Util/InfraCodeRuleRetriever.cs @@ -0,0 +1,188 @@ +using AzureMcp.Areas.Deploy.Models; + +namespace AzureMcp.Areas.Deploy.Services.Util; + +public static class InfraCodeRuleRetriever +{ + public static void PopulateAZDPrompts(string iacType, string[] resourceTypes, List llmResponse) + { + llmResponse.Add("- Ensure an User-Assigned Managed Identity exists."); + llmResponse.Add("- Resource Group resource (if exists) must have tag \"azd-env-name\" = environmentName. Apply this tag to resource group resource ONLY."); + llmResponse.Add($"- Expected parameters in {iacType} parameters: environmentName='${{AZURE_ENV_NAME}}', location='${{AZURE_LOCATION}}'. resourceGroupName='rg-${{AZURE_ENV_NAME}}' is required if scope is subscription."); + llmResponse.Add("- All container apps, app services, function apps, static web apps (and nothing else) must have tag \"azd-service-name\" matching the service name in azure.yaml."); + + var outputsFileName = iacType == IacType.Bicep ? "main.bicep" : "outputs.tf"; + llmResponse.Add($"- Expected output in {outputsFileName}: RESOURCE_GROUP_ID."); + if (resourceTypes.Contains(AzureServiceNames.AzureContainerApp)) + { + llmResponse.Add($"- Expected output in {outputsFileName}: AZURE_CONTAINER_REGISTRY_ENDPOINT representing the URI of the container registry endpoint."); + } + } + + public static void PopulateAzCliPrompts(List llmResponse) + { + // TODO: Enrich Me + llmResponse.Add("- No additional rules."); + } + + public static void PopulateBicepPrompts(List llmResponse) + { + llmResponse.Add("- Expected files: main.bicep, main.parameters.json (with parameters from main.bicep)."); + llmResponse.Add("- Resource token format: 'uniqueString(subscription().id, resourceGroup().id, location, environmentName)' (scope = resourceGroup) or 'uniqueString(subscription().id, location, environmentName)' (scope = subscription)."); + llmResponse.Add("- All resources should be named like az{resourcePrefix}{resourceToken}, where resourcePrefix is a prefix for the resource (ex. 'kv' for key vault) and <= 3 characters. Alphanumeric only. ResourceToken is the string generated by uniqueString as per earlier."); + } + + public static void PopulateTerraformPrompts(List llmResponse) + { + llmResponse.Add("- Expected files: main.tf, main.tfvars.json (with the minimally required parameters), outputs.tf."); + llmResponse.Add("- Resource names should use Azure CAF naming convention. This is required for deployments. Add aztfmod/azurecaf in the required provider. DO NOT use random_length. NO suffixes needed."); + } + + public static string AddPromptForRoleAssignment(string roleId, string roleName, string iacType, string? additionalInstructions = null) + { + var roleAssignmentResourceName = iacType == IacType.Bicep ? "Microsoft.Authorization/roleAssignments" : "azurerm_role_assignment"; + + var returnString = $"- MANDATORY: Add a {roleAssignmentResourceName} resource to assign the {roleName} ({roleId}) role to the user-assigned managed identity"; + + if (!string.IsNullOrEmpty(additionalInstructions)) + { + returnString += $" ({additionalInstructions})"; + } + + return returnString; + } + + public static void PopulateContainerAppPrompts(string iacType, List llmResponse) + { + llmResponse.Add("=== Additional requirements for Container Apps:"); + llmResponse.Add("- Attach User-Assigned Managed Identity."); + llmResponse.Add(AddPromptForRoleAssignment("7f951dda-4ed3-4680-a7ca-43fe172d538d", "AcrPull", iacType, "Only one instance is required per-container registry. Define this BEFORE any container apps.")); + llmResponse.Add("- Use this identity (NOT system) to connect to the container registry. A registry connection needs to be created even if we are using a template base image."); + + const string image = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest"; + var imageProperty = iacType == IacType.Bicep ? "properties.template.containers.image" : "azurerm_container_app.template.container.image"; + llmResponse.Add($"- Container Apps MUST use base container image {image}. The property is set via {imageProperty}."); + + if (iacType == IacType.Bicep) + { + llmResponse.Add("- Enable CORS via properties.configuration.ingress.corsPolicy."); + } + else if (iacType == IacType.Terraform) + { + llmResponse.Add("- Create an ***azapi_resource_action*** resource using :type `Microsoft.App/containerApps`, method `PATCH`, and body `properties.configuration.ingress.corsPolicy` property to enable CORS for all origins, headers, and methods. Use 'azure/azapi' provider version *2.0*. DO NOT use jsonencode() for the body."); + } + + llmResponse.Add("- Define all used secrets; Use Key Vault if possible."); + + if (iacType == IacType.Bicep) + { + llmResponse.Add("- Container App Environment must be connected to Log Analytics Workspace. Use logAnalyticsConfiguration -> customerId=logAnalytics.properties.customerId and sharedKey=logAnalytics.listKeys().primarySharedKey."); + } + else + { + llmResponse.Add("- Container App Environment must be connected to Log Analytics Workspace. Use logs_destination=\"log-analytics\" azurerm_container_app_environment.log_analytics_workspace_id = azurerm_log_analytics_workspace..id."); + } + llmResponse.Add("==="); + } + + public static void PopulateFunctionAppPrompts(string iacType, List llmResponse) + { + llmResponse.Add("=== Additional requirements for Function Apps:"); + llmResponse.Add("- Attach User-Assigned Managed Identity."); + + var requiredRoles = new[] + { + new { RoleId = "b7e6dc6d-f1e8-4753-8033-0f276bb0955b", Name = "Storage Blob Data Owner" }, + new { RoleId = "ba92f5b4-2d11-453d-a403-e96b0029c9fe", Name = "Storage Blob Data Contributor" }, + new { RoleId = "974c5e8b-45b9-4653-ba55-5f855dd0fb88", Name = "Storage Queue Data Contributor" }, + new { RoleId = "0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3", Name = "Storage Table Data Contributor" }, + new { RoleId = "3913510d-42f4-4e42-8a64-420c390055eb", Name = "Monitoring Metrics Publisher" } + }; + + foreach (var role in requiredRoles) + { + llmResponse.Add(AddPromptForRoleAssignment(role.RoleId, role.Name, iacType)); + } + + llmResponse.Add("- Create a storage account and connect to the function app."); + + var diagnosticSettingsResourceType = iacType == IacType.Bicep ? "Microsoft.Insights/diagnosticSettings" : "azurerm_monitor_diagnostic_setting"; + llmResponse.Add($"Define diagnostic settings to save logs. The resource type is {diagnosticSettingsResourceType}."); + llmResponse.Add("==="); + } + + public static void PopulateAppServiceIaCPrompts(string iacType, List llmResponse) + { + llmResponse.Add("App Service Rules:"); + llmResponse.Add("- App Service must be configured with appropriate settings."); + } + + public static List GetInfraCodeRules(string deploymentTool, string iacType, string[] resourceTypes) + { + var llmResponse = new List + { + $"Mandatory rules for deployment. You must implement every rule exactly as stated, with no exceptions or omissions, even if it is not a common pattern or seems redundant. Do not use your own judgment to simplify, skip, or modify any rule. If a rule is present, it must be enforced in the code, regardless of context. Adjust {iacType} files to align with these rules.", + $"Deployment Tool {deploymentTool} rules:" + }; + + if (deploymentTool.Equals(DeploymentTool.Azd, StringComparison.OrdinalIgnoreCase)) + { + PopulateAZDPrompts(iacType, resourceTypes, llmResponse); + } + else if (deploymentTool.Equals(DeploymentTool.AzCli, StringComparison.OrdinalIgnoreCase)) + { + PopulateAzCliPrompts(llmResponse); + } + + llmResponse.Add($"IaC Type: {iacType} rules:"); + + if (iacType == IacType.Bicep) + { + PopulateBicepPrompts(llmResponse); + } + else if (iacType == IacType.Terraform) + { + PopulateTerraformPrompts(llmResponse); + } + + llmResponse.Add($"Resources: {string.Join(", ", resourceTypes)}"); + + if (resourceTypes.Contains(AzureServiceNames.AzureContainerApp)) + { + PopulateContainerAppPrompts(iacType, llmResponse); + } + + if (resourceTypes.Contains(AzureServiceNames.AzureAppService)) + { + PopulateAppServiceIaCPrompts(iacType, llmResponse); + } + + if (resourceTypes.Contains(AzureServiceNames.AzureFunctionApp)) + { + PopulateFunctionAppPrompts(iacType, llmResponse); + } + + llmResponse.Add($"Call get_errors every time you make code changes, otherwise your deployment will fail. You must follow ALL of the previously mentioned rules. DO NOT IGNORE ANY RULES. Call this tool again if need to get the rules again. Show the user a report line-by-line of each rule that was applied. Only skip a rule if there is no corresponding resource (e.g. no function app). Do not stop at error-free code, you must apply all the rules."); + + var necessaryTools = new List { "az cli (az --version)" }; + + if (deploymentTool == DeploymentTool.Azd) + { + necessaryTools.Add("azd (azd --version)"); + } + + if (resourceTypes.Contains(AzureServiceNames.AzureContainerApp)) + { + necessaryTools.Add("docker (docker --version)"); + } + + llmResponse.Add($"Tools needed: {string.Join(",", necessaryTools)}."); + + if (iacType == IacType.Terraform && deploymentTool == DeploymentTool.Azd) + { + llmResponse.Add("Note: Do not use Terraform CLI."); + } + + return llmResponse; + } +} diff --git a/src/Areas/Deploy/Services/Util/JsonElementHelper.cs b/src/Areas/Deploy/Services/Util/JsonElementHelper.cs new file mode 100644 index 000000000..8e1f59bb7 --- /dev/null +++ b/src/Areas/Deploy/Services/Util/JsonElementHelper.cs @@ -0,0 +1,15 @@ +namespace Areas.Server.Commands.Tools.DeployTools.Util; + +public static class JsonElementHelper +{ + public static string GetStringSafe(this JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString() ?? string.Empty, + JsonValueKind.Undefined => string.Empty, + JsonValueKind.Null => string.Empty, + _ => string.Empty + }; + } +} diff --git a/src/Areas/Deploy/Services/Util/PipelineGenerationUtil.cs b/src/Areas/Deploy/Services/Util/PipelineGenerationUtil.cs new file mode 100644 index 000000000..c84e72c6e --- /dev/null +++ b/src/Areas/Deploy/Services/Util/PipelineGenerationUtil.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AzureMcp.Areas.Deploy.Options; + +namespace AzureMcp.Areas.Deploy.Services.Util; + +public static class PipelineGenerationUtil +{ + public static string GeneratePipelineGuidelines(PipelineGenerateOptions options) + { + if (options.UseAZDPipelineConfig) + { + return AZDPipelinePrompt; + } + else + { + return AzCliPipelinePrompt(options); + } + } + + private static readonly string AZDPipelinePrompt = "Run \"azd pipeline config\" to help the user create a deployment pipeline.\n"; + + private static string AzCliPipelinePrompt(PipelineGenerateOptions options) + { + const string defaultEnvironment = "dev"; + var environmentNamePrompt = !string.IsNullOrEmpty(options.GithubEnvironmentName) + ? $"Use {options.GithubEnvironmentName} for environment name of the deployment job." + : $"Use '{defaultEnvironment}' for the $environment for the deployment job."; + + var subscriptionIdPrompt = !string.IsNullOrEmpty(options.Subscription) && CheckGUIDFormat(options.Subscription) + ? $"User is deploying to subscription {options.Subscription}" + : "Use \"az account show --query id -o tsv\" as default subscription ID."; + + var organizationName = !string.IsNullOrEmpty(options.OrganizationName) ? options.OrganizationName : "{$organization-of-repo}"; + var repositoryName = !string.IsNullOrEmpty(options.RepositoryName) ? options.RepositoryName : "{$repository-name}"; + var environmentName = !string.IsNullOrEmpty(options.GithubEnvironmentName) ? options.GithubEnvironmentName : defaultEnvironment; + + var subjectConfig = $"repo:{organizationName}/{repositoryName}:environment:{environmentName}"; + var environmentArg = !string.IsNullOrEmpty(options.GithubEnvironmentName) ? $"--env {options.GithubEnvironmentName}" : "--env dev"; + var environmentCreateCommand = $"gh api --method PUT -H \"Accept: application/vnd.github+json\" repos/{organizationName}/{repositoryName}/environments/{environmentName}"; + var jsonParameters = $"{{\"name\":\"github-federated\",\"issuer\":\"https://token.actions.githubusercontent.com\",\"subject\":\"{subjectConfig}\",\"audiences\":[\"api://AzureADTokenExchange\"]}}"; + return $""" +Help the user to set up a CI/CD pipeline to deploy to Azure with the following steps IN ORDER. **RUN the commands directly and DO NOT just give instructions. DO NOT ask user to provide information.** + + 1. First generate a Github Actions workflow file to deploy to Azure. {environmentNamePrompt} The pipeline at least contains these steps in order: + a. Azure login: login with a service principal using OIDC. DO NOT use secret. + b. Docker build + c. Deploy infrastructure: Use AZ CLI "az deployment sub/group create" command. Use "az deployment sub/group wait" to wait the deployment to finish. Refer to the infra files to set the correct parameters. + d. Azure Container Registry login: login into the container registry created in the previous step. Use "az acr list" to get the correct registry name if you are not sure. + e. Push app images to ACR + f. Deploy to hosting service. Use the infra deployment output or AZ CLI to list hosting resources. Find the name or ID of the hosting resources from "az list" if you are not sure. + + Pay attention to the name of the branches to which the pipeline is triggered. + + 2. Run '{environmentCreateCommand}' to create the environment in the repository. + + 3. - {subscriptionIdPrompt} + - Run "az ad sp create-for-rbac" command to create a service principal. Grant the service principal *Contributor* role of the subscription. Also grant the service principal *User Access Administrator* + **Use Federated credentials in order to authenticate to Azure services from GitHub Actions workflows. The command is **az ad app federated-credential create --id <$service-principal-app-id> --parameters '{jsonParameters}'**. You MUST use ' and \"(DO NOT forget the slash \) in the command. Use the current Github org/repo to fill in the subject property. + + 4. Run command "gh secret set --body {environmentArg}" to configure the AZURE_CLIENT_ID, AZURE_TENANT_ID and AZURE_SUBSCRIPTION_ID of the service principal in Github secrets using Github CLI. + + ** DO NOT prompt user for any information. Find them on your own. ** +"""; + } + + private static bool CheckGUIDFormat(string input) + { + return Guid.TryParse(input, out _); + } +} diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/AzureQuotaChecker.cs b/src/Areas/Deploy/Services/Util/QuotaChecker/AzureQuotaChecker.cs new file mode 100644 index 000000000..216a3f4a9 --- /dev/null +++ b/src/Areas/Deploy/Services/Util/QuotaChecker/AzureQuotaChecker.cs @@ -0,0 +1,179 @@ +using Azure.Core; +using Azure.ResourceManager; +using AzureMcp.Services.Azure.Authentication; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace Areas.Deploy.Services.Util; + +// For simplicity, we currently apply a single rule for all Azure resource providers: +// - Any resource provider not listed in the enum is treated as having no quota limitations. +// Ideally, we'd differentiate between the following cases: +// 1. The resource provider has no quota limitations. +// 2. The resource provider has quota limitations but does not expose a quota API. +// 3. The resource provider exposes a quota API, but it's not yet supported by the checker. + +public enum ResourceProvider +{ + CognitiveServices, + Compute, + Storage, + ContainerApp, + Network, + MachineLearning, + PostgreSQL, + HDInsight, + Search, + ContainerInstance +} + +public record QuotaInfo( + string Name, + int Limit, + int Used, + string? Unit = null, + string? Description = null +); + +public interface IQuotaChecker +{ + Task> GetQuotaForLocationAsync(string location); +} + +// Abstract base class for checking Azure quotas +public abstract class AzureQuotaChecker : IQuotaChecker +{ + protected readonly string SubscriptionId; + protected readonly ArmClient ResourceClient; + + protected readonly TokenCredential Credential; + private static readonly HttpClient HttpClient = new(); + + protected AzureQuotaChecker(TokenCredential credential, string subscriptionId) + { + SubscriptionId = subscriptionId; + Credential = credential ?? throw new ArgumentNullException(nameof(credential)); + ResourceClient = new ArmClient(credential, subscriptionId); + } + + public abstract Task> GetQuotaForLocationAsync(string location); + + protected async Task GetQuotaByUrlAsync(string requestUrl) + { + try + { + var token = await Credential.GetTokenAsync(new TokenRequestContext(["https://management.azure.com/.default"]), CancellationToken.None); + + using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + var response = await HttpClient.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"HTTP error! status: {response.StatusCode}"); + } + + var content = await response.Content.ReadAsStringAsync(); + return JsonDocument.Parse(content); + } + catch (Exception error) + { + Console.WriteLine($"Error fetching quotas directly: {error.Message}"); + return null; + } + } +} + +// Factory function to create quota checkers +public static class QuotaCheckerFactory +{ + private static readonly Dictionary ProviderMapping = new() + { + { "Microsoft.CognitiveServices", ResourceProvider.CognitiveServices }, + { "Microsoft.Compute", ResourceProvider.Compute }, + { "Microsoft.Storage", ResourceProvider.Storage }, + { "Microsoft.App", ResourceProvider.ContainerApp }, + { "Microsoft.Network", ResourceProvider.Network }, + { "Microsoft.MachineLearningServices", ResourceProvider.MachineLearning }, + { "Microsoft.DBforPostgreSQL", ResourceProvider.PostgreSQL }, + { "Microsoft.HDInsight", ResourceProvider.HDInsight }, + { "Microsoft.Search", ResourceProvider.Search }, + { "Microsoft.ContainerInstance", ResourceProvider.ContainerInstance } + }; + + public static IQuotaChecker CreateQuotaChecker(TokenCredential credential, string provider, string subscriptionId) + { + if (!ProviderMapping.TryGetValue(provider, out var resourceProvider)) + { + throw new ArgumentException($"Unsupported resource provider: {provider}"); + } + + return resourceProvider switch + { + ResourceProvider.Compute => new ComputeQuotaChecker(credential, subscriptionId), + ResourceProvider.CognitiveServices => new CognitiveServicesQuotaChecker(credential, subscriptionId), + ResourceProvider.Storage => new StorageQuotaChecker(credential, subscriptionId), + ResourceProvider.ContainerApp => new ContainerAppQuotaChecker(credential, subscriptionId), + ResourceProvider.Network => new NetworkQuotaChecker(credential, subscriptionId), + ResourceProvider.MachineLearning => new MachineLearningQuotaChecker(credential, subscriptionId), + ResourceProvider.PostgreSQL => new PostgreSQLQuotaChecker(credential, subscriptionId), + ResourceProvider.HDInsight => new HDInsightQuotaChecker(credential, subscriptionId), + ResourceProvider.Search => new SearchQuotaChecker(credential, subscriptionId), + ResourceProvider.ContainerInstance => new ContainerInstanceQuotaChecker(credential, subscriptionId), + _ => throw new ArgumentException($"No implementation for provider: {provider}") + }; + } +} + +// Service to get Azure quota for a list of resource types +public static class AzureQuotaService +{ + public static async Task>> GetAzureQuotaAsync( + TokenCredential credential, + List resourceTypes, + string subscriptionId, + string location) + { + // Group resource types by provider to avoid duplicate processing + var providerToResourceTypes = resourceTypes + .GroupBy(rt => rt.Split('/')[0]) + .ToDictionary(g => g.Key, g => g.ToList()); + + // Use Select to create tasks and await them all + var quotaTasks = providerToResourceTypes.Select(async kvp => + { + var (provider, resourceTypesForProvider) = (kvp.Key, kvp.Value); + try + { + var quotaChecker = QuotaCheckerFactory.CreateQuotaChecker(credential, provider, subscriptionId); + var quotaInfo = await quotaChecker.GetQuotaForLocationAsync(location); + Console.WriteLine($"Quota info for provider {provider}: {quotaInfo.Count} items"); + + return resourceTypesForProvider.Select(rt => new KeyValuePair>(rt, quotaInfo)); + } + catch (ArgumentException ex) when (ex.Message.Contains("Unsupported resource provider", StringComparison.OrdinalIgnoreCase)) + { + return resourceTypesForProvider.Select(rt => new KeyValuePair>(rt, new List(){ + new QuotaInfo(rt, 0, 0, Description: "No Limit") + })); + } + catch (Exception error) + { + Console.WriteLine($"Error fetching quota for provider {provider}: {error.Message}"); + return resourceTypesForProvider.Select(rt => new KeyValuePair>(rt, new List() + { + new QuotaInfo(rt, 0, 0, Description: error.Message) + })); + } + }); + + var results = await Task.WhenAll(quotaTasks); + + // Flatten the results into a single dictionary + return results + .SelectMany(i => i) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } +} diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/CognitiveServicesQuotaChecker.cs b/src/Areas/Deploy/Services/Util/QuotaChecker/CognitiveServicesQuotaChecker.cs new file mode 100644 index 000000000..9edb158c1 --- /dev/null +++ b/src/Areas/Deploy/Services/Util/QuotaChecker/CognitiveServicesQuotaChecker.cs @@ -0,0 +1,36 @@ +using Azure.Core; +using Azure.ResourceManager.CognitiveServices; +using Azure.ResourceManager.CognitiveServices.Models; + +namespace Areas.Deploy.Services.Util; + +public class CognitiveServicesQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +{ + public override async Task> GetQuotaForLocationAsync(string location) + { + try + { + var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); + var usages = subscription.GetUsagesAsync(location); + var result = new List(); + + await foreach (ServiceAccountUsage item in usages) + { + result.Add(new QuotaInfo( + Name: item.Name?.LocalizedValue ?? item.Name?.Value ?? string.Empty, + Limit: (int)(item.Limit ?? 0), + Used: (int)(item.CurrentValue ?? 0), + Unit: item.Unit.ToString(), + Description: null + )); + } + + return result; + } + catch (Exception error) + { + Console.WriteLine($"Error fetching cognitive services quotas: {error.Message}"); + return []; + } + } +} diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/ComputeQuotaChecker.cs b/src/Areas/Deploy/Services/Util/QuotaChecker/ComputeQuotaChecker.cs new file mode 100644 index 000000000..983d210d8 --- /dev/null +++ b/src/Areas/Deploy/Services/Util/QuotaChecker/ComputeQuotaChecker.cs @@ -0,0 +1,36 @@ +using Azure.Core; +using Azure.ResourceManager; +using Azure.ResourceManager.Compute; +using Azure.ResourceManager.Compute.Models; + +namespace Areas.Deploy.Services.Util; + +public class ComputeQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +{ + public override async Task> GetQuotaForLocationAsync(string location) + { + try + { + var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); + var usages = subscription.GetUsagesAsync(location); + var result = new List(); + + await foreach (ComputeUsage item in usages) + { + result.Add(new QuotaInfo( + Name: item.Name?.LocalizedValue ?? item.Name?.Value ?? string.Empty, + Limit: (int)item.Limit, + Used: (int)item.CurrentValue, + Unit: item.Unit.ToString() + )); + } + + return result; + } + catch (Exception error) + { + Console.WriteLine($"Error fetching compute quotas: {error.Message}"); + return []; + } + } +} diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/ContainerAppQuotaChecker.cs b/src/Areas/Deploy/Services/Util/QuotaChecker/ContainerAppQuotaChecker.cs new file mode 100644 index 000000000..17dffe220 --- /dev/null +++ b/src/Areas/Deploy/Services/Util/QuotaChecker/ContainerAppQuotaChecker.cs @@ -0,0 +1,35 @@ +using Azure.Core; +using Azure.ResourceManager.AppContainers; +using Azure.ResourceManager.AppContainers.Models; + +namespace Areas.Deploy.Services.Util; + +public class ContainerAppQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +{ + public override async Task> GetQuotaForLocationAsync(string location) + { + try + { + var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); + var usages = subscription.GetUsagesAsync(location); + var result = new List(); + + await foreach (var item in usages) + { + result.Add(new QuotaInfo( + Name: item.Name?.Value ?? string.Empty, + Limit: (int)(item.Limit), + Used: (int)(item.CurrentValue), + Unit: item.Unit.ToString() + )); + } + + return result; + } + catch (Exception error) + { + Console.WriteLine($"Error fetching Container Apps quotas: {error.Message}"); + return []; + } + } +} diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/ContainerInstanceQuotaChecker.cs b/src/Areas/Deploy/Services/Util/QuotaChecker/ContainerInstanceQuotaChecker.cs new file mode 100644 index 000000000..f94376fa2 --- /dev/null +++ b/src/Areas/Deploy/Services/Util/QuotaChecker/ContainerInstanceQuotaChecker.cs @@ -0,0 +1,35 @@ +using Azure.Core; +using Azure.ResourceManager.ContainerInstance; +using Azure.ResourceManager.ContainerInstance.Models; + +namespace Areas.Deploy.Services.Util; + +public class ContainerInstanceQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +{ + public override async Task> GetQuotaForLocationAsync(string location) + { + try + { + var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); + var usages = subscription.GetUsagesWithLocationAsync(location); + + var result = new List(); + await foreach (ContainerInstanceUsage item in usages) + { + result.Add(new QuotaInfo( + Name: item.Name?.LocalizedValue ?? item.Name?.Value ?? string.Empty, + Limit: (int)(item.Limit ?? 0), + Used: (int)(item.CurrentValue ?? 0), + Unit: item.Unit.ToString() + )); + } + + return result; + } + catch (Exception error) + { + Console.WriteLine($"Error fetching Container Instance quotas: {error.Message}"); + return []; + } + } +} diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/HDInsightQuotaChecker.cs b/src/Areas/Deploy/Services/Util/QuotaChecker/HDInsightQuotaChecker.cs new file mode 100644 index 000000000..fa59a90c1 --- /dev/null +++ b/src/Areas/Deploy/Services/Util/QuotaChecker/HDInsightQuotaChecker.cs @@ -0,0 +1,34 @@ +using Azure.Core; +using Azure.ResourceManager.HDInsight; +using Azure.ResourceManager.HDInsight.Models; + +namespace Areas.Deploy.Services.Util; + +public class HDInsightQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +{ + public override async Task> GetQuotaForLocationAsync(string location) + { + try + { + var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); + var usages = subscription.GetHDInsightUsagesAsync(location); + var result = new List(); + + await foreach (HDInsightUsage item in usages) + { + result.Add(new QuotaInfo( + Name: item.Name?.LocalizedValue ?? item.Name?.Value ?? string.Empty, + Limit: (int)(item.Limit ?? 0), + Used: (int)(item.CurrentValue ?? 0), + Unit: item.Unit.ToString() + )); + } + return result; + } + catch (Exception error) + { + Console.WriteLine($"Error fetching HDInsight quotas: {error.Message}"); + return []; + } + } +} diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/MachineLearningQuotaChecker.cs b/src/Areas/Deploy/Services/Util/QuotaChecker/MachineLearningQuotaChecker.cs new file mode 100644 index 000000000..6cd1e1fad --- /dev/null +++ b/src/Areas/Deploy/Services/Util/QuotaChecker/MachineLearningQuotaChecker.cs @@ -0,0 +1,34 @@ +using Azure.Core; +using Azure.ResourceManager.MachineLearning; + +namespace Areas.Deploy.Services.Util; + +public class MachineLearningQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +{ + public override async Task> GetQuotaForLocationAsync(string location) + { + try + { + var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); + var usages = subscription.GetMachineLearningUsagesAsync(location); + var result = new List(); + + await foreach (var item in usages) + { + result.Add(new QuotaInfo( + Name: item.Name?.Value ?? string.Empty, + Limit: (int)(item.Limit ?? 0), + Used: (int)(item.CurrentValue ?? 0), + Unit: item.Unit.ToString() + )); + } + + return result; + } + catch (Exception error) + { + Console.WriteLine($"Error fetching Machine Learning Services quotas: {error.Message}"); + return []; + } + } +} diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/NetworkQuotaChecker.cs b/src/Areas/Deploy/Services/Util/QuotaChecker/NetworkQuotaChecker.cs new file mode 100644 index 000000000..e8644a79d --- /dev/null +++ b/src/Areas/Deploy/Services/Util/QuotaChecker/NetworkQuotaChecker.cs @@ -0,0 +1,34 @@ +using Azure.Core; +using Azure.ResourceManager.Network; + +namespace Areas.Deploy.Services.Util; + +public class NetworkQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +{ + public override async Task> GetQuotaForLocationAsync(string location) + { + try + { + var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); + var usages = subscription.GetUsagesAsync(location); + var result = new List(); + + await foreach (var item in usages) + { + result.Add(new QuotaInfo( + Name: item.Name?.Value ?? string.Empty, + Limit: (int)(item.Limit), + Used: (int)(item.CurrentValue), + Unit: item.Unit.ToString() + )); + } + + return result; + } + catch (Exception error) + { + Console.WriteLine($"Error fetching network quotas: {error.Message}"); + return []; + } + } +} diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/PostgreSQLQuotaChecker.cs b/src/Areas/Deploy/Services/Util/QuotaChecker/PostgreSQLQuotaChecker.cs new file mode 100644 index 000000000..fc13200b3 --- /dev/null +++ b/src/Areas/Deploy/Services/Util/QuotaChecker/PostgreSQLQuotaChecker.cs @@ -0,0 +1,59 @@ +using Areas.Server.Commands.Tools.DeployTools.Util; +using Azure.Core; + +namespace Areas.Deploy.Services.Util; + +public class PostgreSQLQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +{ + public override async Task> GetQuotaForLocationAsync(string location) + { + try + { + var requestUrl = $"https://management.azure.com/subscriptions/{SubscriptionId}/providers/Microsoft.DBforPostgreSQL/locations/{location}/resourceType/flexibleServers/usages?api-version=2023-06-01-preview"; + using var rawResponse = await GetQuotaByUrlAsync(requestUrl); + + if (rawResponse?.RootElement.TryGetProperty("value", out var valueElement) != true) + { + return []; + } + + var result = new List(); + foreach (var item in valueElement.EnumerateArray()) + { + var name = string.Empty; + var limit = 0; + var used = 0; + var unit = string.Empty; + + if (item.TryGetProperty("name", out var nameElement) && nameElement.TryGetProperty("value", out var nameValue)) + { + name = nameValue.GetStringSafe(); + } + + if (item.TryGetProperty("limit", out var limitElement)) + { + limit = limitElement.GetInt32(); + } + + if (item.TryGetProperty("currentValue", out var usedElement)) + { + used = usedElement.GetInt32(); + } + + if (item.TryGetProperty("unit", out var unitElement)) + { + unit = unitElement.GetStringSafe(); + } + + result.Add(new QuotaInfo(name, limit, used, unit)); + } + + return result; + } + catch (Exception error) + { + Console.WriteLine($"Error fetching PostgreSQL quotas: {error.Message}"); + return []; + } + } +} diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/SearchQuotaChecker.cs b/src/Areas/Deploy/Services/Util/QuotaChecker/SearchQuotaChecker.cs new file mode 100644 index 000000000..aab2356b5 --- /dev/null +++ b/src/Areas/Deploy/Services/Util/QuotaChecker/SearchQuotaChecker.cs @@ -0,0 +1,35 @@ +using Azure.Core; +using Azure.ResourceManager.Search; +using Azure.ResourceManager.Search.Models; + +namespace Areas.Deploy.Services.Util; + +public class SearchQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +{ + public override async Task> GetQuotaForLocationAsync(string location) + { + try + { + var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); + var usages = subscription.GetUsagesBySubscriptionAsync(location); + var result = new List(); + + await foreach (QuotaUsageResult item in usages) + { + result.Add(new QuotaInfo( + Name: item.Name?.Value ?? string.Empty, + Limit: item.Limit ?? 0, + Used: item.CurrentValue ?? 0, + Unit: item.Unit.ToString() + )); + } + + return result; + } + catch (Exception error) + { + Console.WriteLine($"Error fetching Search quotas: {error.Message}"); + return []; + } + } +} diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/StorageQuotaChecker.cs b/src/Areas/Deploy/Services/Util/QuotaChecker/StorageQuotaChecker.cs new file mode 100644 index 000000000..0bbcf5136 --- /dev/null +++ b/src/Areas/Deploy/Services/Util/QuotaChecker/StorageQuotaChecker.cs @@ -0,0 +1,36 @@ +using Azure.Core; +using Azure.ResourceManager; +using Azure.ResourceManager.Storage; +using Azure.ResourceManager.Storage.Models; + +namespace Areas.Deploy.Services.Util; + +public class StorageQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +{ + public override async Task> GetQuotaForLocationAsync(string location) + { + try + { + var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); + var usages = subscription.GetUsagesByLocationAsync(location); + var result = new List(); + + await foreach (var item in usages) + { + result.Add(new QuotaInfo( + Name: item.Name?.Value ?? string.Empty, + Limit: item.Limit ?? 0, + Used: item.CurrentValue ?? 0, + Unit: item.Unit.ToString() + )); + } + + return result; + } + catch (Exception error) + { + Console.WriteLine($"Error fetching storage quotas: {error.Message}"); + return []; + } + } +} diff --git a/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs b/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs index eb4b17eae..844e6efab 100644 --- a/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs +++ b/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs @@ -36,6 +36,8 @@ public sealed class CommandFactoryToolLoader( : commandFactory.GroupCommands(options.Value.Namespace); private readonly ILogger _logger = logger; + public const string RawMcpToolInputOptionName = "rawMcpToolInput"; + /// /// Gets whether the tool loader operates in read-only mode. /// @@ -120,7 +122,16 @@ public async ValueTask CallToolHandler(RequestContext 0) { - var arguments = new JsonObject(); - foreach (var option in options) + if (options.Count == 1 && options[0].Name == RawMcpToolInputOptionName) + { + var arguments = JsonNode.Parse(options[0].Description ?? "{}") as JsonObject ?? new JsonObject(); + schema = arguments; + } + else { - arguments.Add(option.Name, new JsonObject() + var arguments = new JsonObject(); + foreach (var option in options) { - ["type"] = option.ValueType.ToJsonType(), - ["description"] = option.Description, - }); + arguments.Add(option.Name, new JsonObject() + { + ["type"] = option.ValueType.ToJsonType(), + ["description"] = option.Description, + }); + } + + schema["properties"] = arguments; + schema["required"] = new JsonArray(options.Where(p => p.IsRequired).Select(p => (JsonNode)p.Name).ToArray()); } - - schema["properties"] = arguments; - schema["required"] = new JsonArray(options.Where(p => p.IsRequired).Select(p => (JsonNode)p.Name).ToArray()); } else { diff --git a/src/AzureMcp.csproj b/src/AzureMcp.csproj index f104a2b15..e87afaba7 100644 --- a/src/AzureMcp.csproj +++ b/src/AzureMcp.csproj @@ -68,6 +68,15 @@ + + + + + + + + + @@ -96,6 +105,7 @@ + diff --git a/src/Commands/CommandExtensions.cs b/src/Commands/CommandExtensions.cs index cfd92d6a4..6292674fb 100644 --- a/src/Commands/CommandExtensions.cs +++ b/src/Commands/CommandExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.Json.Nodes; + namespace AzureMcp.Commands; /// @@ -54,4 +56,28 @@ public static ParseResult ParseFromDictionary(this Command command, IReadOnlyDic return command.Parse(args.ToArray()); } + + public static ParseResult ParseFromRawMcpToolInput(this Command command, IReadOnlyDictionary? arguments) + { + var args = new List(); + var option = command.Options[0]; + args.Add($"--{option.Name}"); + + if (arguments == null || arguments.Count == 0) + { + args.Add("{}"); + } + else + { + var jsonObject = new JsonObject(); + foreach (var (key, value) in arguments) + { + jsonObject[key] = JsonNode.Parse(value.GetRawText()); + } + var jsonString = jsonObject.ToJsonString(); + args.Add(jsonString); + } + + return command.Parse(args.ToArray()); + } } diff --git a/src/Program.cs b/src/Program.cs index 1d4ed65ca..11c9a2334 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -76,6 +76,7 @@ private static IAreaSetup[] RegisterAreas() new AzureMcp.Areas.Sql.SqlSetup(), new AzureMcp.Areas.Storage.StorageSetup(), new AzureMcp.Areas.BicepSchema.BicepSchemaSetup(), + new AzureMcp.Areas.Deploy.DeploySetup(), new AzureMcp.Areas.AzureTerraformBestPractices.AzureTerraformBestPracticesSetup(), new AzureMcp.Areas.LoadTesting.LoadTestingSetup(), ]; diff --git a/tests/Areas/Deploy/LiveTests/DeployCommandTests.cs b/tests/Areas/Deploy/LiveTests/DeployCommandTests.cs new file mode 100644 index 000000000..0e4c1b3c1 --- /dev/null +++ b/tests/Areas/Deploy/LiveTests/DeployCommandTests.cs @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using AzureMcp.Areas.Deploy.Models; +using AzureMcp.Areas.Deploy.Services; +using AzureMcp.Tests.Client; +using AzureMcp.Tests.Client.Helpers; +using ModelContextProtocol.Client; +using Xunit; + +namespace AzureMcp.Tests.Areas.Deploy.LiveTests; + +[Trait("Area", "Deploy")] +public class DeployCommandTests : CommandTestsBase, + IClassFixture +{ + private readonly string _subscriptionId; + + public DeployCommandTests(LiveTestFixture liveTestFixture, ITestOutputHelper output) : base(liveTestFixture, output) + { + _subscriptionId = Settings.SubscriptionId; + } + + [Fact] + [Trait("Category", "Live")] + public async Task Should_get_plan() + { + // act + var result = await CallToolMessageAsync( + "azmcp-deploy-plan-get", + new() + { + { "workspace-folder", "C:/" }, + { "project-name", "django" } + }); + // assert + Assert.StartsWith(result, "Title:"); + } + + [Fact] + [Trait("Category", "Live")] + public async Task Should_check_azure_quota() + { + JsonElement? result = await CallToolAsync( + "azmcp-deploy-quota-check", + new() { + { "subscription", _subscriptionId }, + { "region", "eastus" }, + { "resource-types", "Microsoft.App, Microsoft.Storage/storageAccounts" } + }); + // assert + var quotas = result.AssertProperty("quotaInfo"); + Assert.Equal(JsonValueKind.Object, quotas.ValueKind); + var appQuotas = quotas.AssertProperty("Microsoft.App"); + Assert.Equal(JsonValueKind.Array, appQuotas.ValueKind); + Assert.NotEmpty(appQuotas.EnumerateArray()); + var storageQuotas = quotas.AssertProperty("Microsoft.Storage/storageAccounts"); + Assert.Equal(JsonValueKind.Array, storageQuotas.ValueKind); + Assert.NotEmpty(storageQuotas.EnumerateArray()); + } + + [Fact] + [Trait("Category", "Live")] + public async Task Should_get_infrastructure_code_rules() + { + // arrange + var parameters = new + { + deploymentTool = "azd", + iacType = "bicep", + resourceTypes = new[] { "appservice", "azurestorage" } + }; + + // act + var result = await CallToolMessageAsync( + "azmcp-deploy-infra-code-rules-get", + new() + { + { "deployment-tool", "azd" }, + { "iac-type", "bicep" }, + { "resource-types", "appservice, azurestorage" } + }); + + Assert.Contains("Deployment Tool: azd", result ?? String.Empty, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + [Trait("Category", "Live")] + public async Task Should_get_infrastructure_rules_for_terraform() + { + // act + var result = await CallToolMessageAsync( + "azmcp-deploy-infra-code-rules-get", + new() + { + { "deployment-tool", "azd" }, + { "iac-type", "terraform" }, + { "resource-types", "containerapp, azurecosmosdb" } + }); + + // assert + Assert.Contains("IaC Type: terraform. IaC Type rules:", result ?? String.Empty, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + [Trait("Category", "Live")] + public async Task Should_generate_pipeline() + { + // act + var result = await CallToolMessageAsync( + "azmcp-deploy-pipeline-generate", + new() + { + { "subscription", _subscriptionId }, + { "use-azd-pipeline-config", true } + }); + + // assert + Assert.Contains("Run \"azd pipeline config\" to help the user create a deployment pipeline.", result); + } + + [Fact] + [Trait("Category", "Live")] + public async Task Should_generate_pipeline_with_github_details() + { + // act + var result = await CallToolMessageAsync( + "azmcp-deploy-pipeline-generate", + new() + { + { "subscription", _subscriptionId }, + { "use-azd-pipeline-config", false }, + { "organization-name", "test-org" }, + { "repository-name", "test-repo" }, + { "github-environment-name", "production" } + }); + + // assert + Assert.StartsWith("Help the user to set up a CI/CD pipeline", result ?? String.Empty); + } + + + [Fact] + [Trait("Category", "Live")] + public async Task Should_get_azd_app_logs() + { + // act + var result = await CallToolMessageAsync( + "azmcp-deploy-azd-app-log-get", + new() + { + { "subscription", _subscriptionId }, + { "workspace-folder", "C:/Users/" }, + { "azd-env-name", "dotnet-demo" }, + { "limit", 10 } + }); + + // assert + Assert.StartsWith("App logs retrieved:", result); + } + + [Fact] + [Trait("Category", "Live")] + public async Task Should_check_azure_regions() + { + // act + var result = await CallToolAsync( + "azmcp-deploy-region-check", + new() + { + { "subscription", _subscriptionId }, + { "resource-types", "Microsoft.Web/sites, Microsoft.Storage/storageAccounts" }, + }); + + // assert + var availableRegions = result.AssertProperty("availableRegions"); + Assert.Equal(JsonValueKind.Array, availableRegions.ValueKind); + Assert.NotEmpty(availableRegions.EnumerateArray()); + } + + [Fact] + [Trait("Category", "Live")] + public async Task Should_check_regions_with_cognitive_services() + { + // act + var result = await CallToolAsync( + "azmcp-deploy-region-check", + new() + { + { "subscription", _subscriptionId }, + { "resource-types", "Microsoft.CognitiveServices/accounts" }, + { "cognitive-service-model-name", "gpt-4o" }, + { "cognitive-service-deployment-sku-name", "Standard" } + }); + + // assert + var availableRegions = result.AssertProperty("availableRegions"); + Assert.Equal(JsonValueKind.Array, availableRegions.ValueKind); + Assert.NotEmpty(availableRegions.EnumerateArray()); + } + + private async Task CallToolMessageAsync(string command, Dictionary parameters) + { + // Output will be streamed, so if we're not in debug mode, hold the debug output for logging in the failure case + Action writeOutput = Settings.DebugOutput + ? s => Output.WriteLine(s) + : s => FailureOutput.AppendLine(s); + + writeOutput($"request: {JsonSerializer.Serialize(new { command, parameters })}"); + + var result = await Client.CallToolAsync(command, parameters); + + var content = McpTestUtilities.GetFirstText(result.Content); + if (string.IsNullOrWhiteSpace(content)) + { + Output.WriteLine($"response: {JsonSerializer.Serialize(result)}"); + throw new Exception("No JSON content found in the response."); + } + + var root = JsonSerializer.Deserialize(content!); + if (root.ValueKind != JsonValueKind.Object) + { + Output.WriteLine($"response: {JsonSerializer.Serialize(result)}"); + throw new Exception("Invalid JSON response."); + } + + // Remove the `args` property and log the content + var trimmed = root.Deserialize()!; + trimmed.Remove("args"); + writeOutput($"response content: {trimmed.ToJsonString(new JsonSerializerOptions { WriteIndented = true })}"); + + return root.TryGetProperty("message", out var property) ? property.GetString() : null; + } + +} diff --git a/tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs b/tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs new file mode 100644 index 000000000..7e352e297 --- /dev/null +++ b/tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Text.Json; +using System.Text.Json.Serialization; +using AzureMcp.Areas.Deploy.Commands; +using AzureMcp.Areas.Deploy.Options; +using AzureMcp.Models.Command; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace AzureMcp.Tests.Areas.Deploy.UnitTests; + +[Trait("Area", "Deploy")] +public class ArchitectureDiagramTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public ArchitectureDiagramTests() + { + _logger = Substitute.For>(); + + var collection = new ServiceCollection(); + _serviceProvider = collection.BuildServiceProvider(); + } + + + [Fact] + public async Task GenerateArchitectureDiagram_ShouldReturnNoServiceDetected() + { + var command = new GenerateArchitectureDiagramCommand(_logger); + var args = command.GetCommand().Parse(["--rawMcpToolInput", "{\"projectName\": \"test\",\"services\": []}"]); + var context = new CommandContext(_serviceProvider); + var response = await command.ExecuteAsync(context, args); + Assert.NotNull(response); + Assert.Equal(200, response.Status); + Assert.Contains("No service detected", response.Message); + } + + [Fact] + public async Task GenerateArchitectureDiagram_ShouldReturnEncryptedDiagramUrl() + { + var command = new GenerateArchitectureDiagramCommand(_logger); + var appTopology = new AppTopology() + { + WorkspaceFolder = "testWorkspace", + ProjectName = "testProject", + Services = new ServiceConfig[] + { + new ServiceConfig + { + Name = "website", + AzureComputeHost = "appservice", + Language = "dotnet", + Port = "80", + Dependencies = new DependencyConfig[] + { + new DependencyConfig { Name = "store", ConnectionType = "system-identity", ServiceType = "azurestorageaccount" } + }, + } + } + }; + + var args = command.GetCommand().Parse(["--rawMcpToolInput", JsonSerializer.Serialize(appTopology)]); + var context = new CommandContext(_serviceProvider); + var response = await command.ExecuteAsync(context, args); + Assert.NotNull(response); + Assert.Equal(200, response.Status); + // Extract the URL from the response message + var urlPattern = "https://mermaid.live/view#pako:"; + var urlStartIndex = response.Message.IndexOf(urlPattern); + Assert.True(urlStartIndex >= 0, "URL starting with 'https://mermaid.live/view#pako:' should be present in the response"); + + // Extract the full URL (assuming it ends at whitespace or end of string) + var urlStartPosition = urlStartIndex; + var urlEndPosition = response.Message.IndexOfAny([' ', '\n', '\r', '\t'], urlStartPosition); + if (urlEndPosition == -1) + urlEndPosition = response.Message.Length; + + var extractedUrl = response.Message.Substring(urlStartPosition, urlEndPosition - urlStartPosition); + Assert.StartsWith(urlPattern, extractedUrl); + var encodedDiagram = extractedUrl.Substring(urlPattern.Length); + var decodedDiagram = EncodeMermaid.GetDecodedMermaidChart(encodedDiagram); + Assert.NotEmpty(decodedDiagram); + Assert.Contains("website", decodedDiagram); + Assert.Contains("store", decodedDiagram); + } +} \ No newline at end of file diff --git a/tests/Areas/Server/UnitTests/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs b/tests/Areas/Server/UnitTests/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs index 578100890..8f1983a02 100644 --- a/tests/Areas/Server/UnitTests/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs +++ b/tests/Areas/Server/UnitTests/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs @@ -295,4 +295,60 @@ public async Task CallToolHandler_WithUnknownTool_ReturnsError() Assert.NotNull(textContent); Assert.Contains("Could not find command: non-existent-tool", textContent.Text); } + + [Fact] + public async Task GetsToolsWithRawMcpInputOption() + { + var multiServiceOptions = new ServiceStartOptions + { + Namespace = new[] { "deploy" } // Real Azure service groups from the codebase + }; + var (toolLoader, commandFactory) = CreateToolLoader(multiServiceOptions); + var request = CreateRequest(); + var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + + Assert.NotNull(result); + Assert.NotEmpty(result.Tools); + + var tool = result.Tools.FirstOrDefault(tool => + tool.Name.Equals("deploy_architecture-diagram-generate", StringComparison.OrdinalIgnoreCase)); + Assert.NotNull(tool); + Assert.NotNull(tool.Name); + Assert.NotNull(tool.Description!); + Assert.NotNull(tool.Annotations); + + Assert.Equal(JsonValueKind.Object, tool.InputSchema.ValueKind); + + foreach (var properties in tool.InputSchema.EnumerateObject()) + { + if (properties.NameEquals("type")) + { + Assert.Equal("object", properties.Value.GetString()); + } + + if (!properties.NameEquals("properties")) + { + continue; + } + + var commandArguments = properties.Value.EnumerateObject().ToArray(); + Assert.Contains(commandArguments, arg => arg.Name.Equals("projectName", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(commandArguments, arg => arg.Name.Equals("services", StringComparison.OrdinalIgnoreCase) && + arg.Value.GetProperty("type").GetString() == "array"); + var servicesArgument = commandArguments.FirstOrDefault(arg => arg.Name.Equals("services", StringComparison.OrdinalIgnoreCase)); + if (servicesArgument.Value.ValueKind != JsonValueKind.Undefined) + { + if (servicesArgument.Value.TryGetProperty("items", out var itemsProperty)) + { + if (itemsProperty.TryGetProperty("properties", out var servicesProperties)) + { + var servicePropertyArgs = servicesProperties.EnumerateObject().ToArray(); + Assert.Contains(servicePropertyArgs, prop => prop.Name.Equals("dependencies", StringComparison.OrdinalIgnoreCase) && + prop.Value.GetProperty("type").GetString() == "array"); + } + } + } + } + } } + diff --git a/tests/Commands/Extensions/CommandExtensionsTests.cs b/tests/Commands/Extensions/CommandExtensionsTests.cs index 465b119ad..c4f95c10d 100644 --- a/tests/Commands/Extensions/CommandExtensionsTests.cs +++ b/tests/Commands/Extensions/CommandExtensionsTests.cs @@ -320,4 +320,25 @@ public void ParseFromDictionary_WithMixedQuotesInValues_ParsesCorrectly() Assert.Empty(result.Errors); Assert.Equal("echo \"User's home: '$HOME'\" && echo 'Path: \"$PATH\"'", result.GetValueForOption(scriptOption)); } + + [Fact] + public void ParseFromRawMcpToolInput() + { + // Arrange + var command = new Command("test"); + var scriptOption = new Option("--rawMcpToolInput") { IsRequired = true }; + command.AddOption(scriptOption); + + var arguments = new Dictionary + { + { "name", JsonSerializer.SerializeToElement("abc") }, + { "path", JsonSerializer.SerializeToElement("123") } + }; + + // Act + var result = command.ParseFromRawMcpToolInput(arguments); // Assert + Assert.NotNull(result); + Assert.Empty(result.Errors); + Assert.Equal("{\"name\":\"abc\",\"path\":\"123\"}", result.GetValueForOption(scriptOption)); + } } From a9215a5d1e19f6aead7f35b685c2ecf0ac7ac6af Mon Sep 17 00:00:00 2001 From: qianwens Date: Thu, 17 Jul 2025 16:28:03 +0800 Subject: [PATCH 02/56] fix source analysis errors --- src/Areas/Deploy/Commands/DeployJsonContext.cs | 6 +++--- src/Areas/Deploy/Commands/EncodeMermaid.cs | 8 ++++---- .../Commands/GenerateArchitectureDiagramCommand.cs | 4 ++-- src/Areas/Deploy/Commands/GenerateMermaidChart.cs | 2 +- src/Areas/Deploy/DeploySetup.cs | 14 +++++++------- src/Areas/Deploy/Options/AppTopology.cs | 2 +- src/Areas/Deploy/Options/RawMcpToolInputOptions.cs | 2 +- .../Deploy/Services/Util/AzdAppLogRetriever.cs | 6 +++--- .../Util/QuotaChecker/AzureQuotaChecker.cs | 4 ++-- src/Commands/CommandExtensions.cs | 2 +- .../Deploy/UnitTests/ArchitectureDiagramTests.cs | 2 +- .../ToolLoading/CommandFactoryToolLoaderTests.cs | 4 ++-- 12 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/Areas/Deploy/Commands/DeployJsonContext.cs b/src/Areas/Deploy/Commands/DeployJsonContext.cs index b85a4956b..914657568 100644 --- a/src/Areas/Deploy/Commands/DeployJsonContext.cs +++ b/src/Areas/Deploy/Commands/DeployJsonContext.cs @@ -2,11 +2,11 @@ // Licensed under the MIT License. using System.Text.Json.Serialization; -using AzureMcp.Areas.Deploy.Options; -using AzureMcp.Areas.Deploy.Models; +using Areas.Deploy.Services.Util; using AzureMcp.Areas.Deploy.Commands.Quota; using AzureMcp.Areas.Deploy.Commands.Region; -using Areas.Deploy.Services.Util; +using AzureMcp.Areas.Deploy.Models; +using AzureMcp.Areas.Deploy.Options; namespace AzureMcp.Areas.Deploy.Commands; diff --git a/src/Areas/Deploy/Commands/EncodeMermaid.cs b/src/Areas/Deploy/Commands/EncodeMermaid.cs index efa64ebfc..56c3a0aa1 100644 --- a/src/Areas/Deploy/Commands/EncodeMermaid.cs +++ b/src/Areas/Deploy/Commands/EncodeMermaid.cs @@ -30,13 +30,13 @@ public static string GetEncodedMermaidChart(string graph) public static string GetDecodedMermaidChart(string encodedChart) { byte[] compressedData = Convert.FromBase64String(encodedChart); - + byte[] decompressedData = DecompressData(compressedData); - + string jsonString = Encoding.UTF8.GetString(decompressedData); - + MermaidData? data = JsonSerializer.Deserialize(jsonString, DeployJsonContext.Default.MermaidData); - + return data?.Code ?? string.Empty; } diff --git a/src/Areas/Deploy/Commands/GenerateArchitectureDiagramCommand.cs b/src/Areas/Deploy/Commands/GenerateArchitectureDiagramCommand.cs index 73852651a..6856e95b3 100644 --- a/src/Areas/Deploy/Commands/GenerateArchitectureDiagramCommand.cs +++ b/src/Areas/Deploy/Commands/GenerateArchitectureDiagramCommand.cs @@ -3,11 +3,11 @@ using System.Reflection; using System.Text.Json; +using System.Text.Json.Nodes; +using AzureMcp.Areas.Deploy.Options; using AzureMcp.Commands; using AzureMcp.Helpers; using Microsoft.Extensions.Logging; -using AzureMcp.Areas.Deploy.Options; -using System.Text.Json.Nodes; namespace AzureMcp.Areas.Deploy.Commands; diff --git a/src/Areas/Deploy/Commands/GenerateMermaidChart.cs b/src/Areas/Deploy/Commands/GenerateMermaidChart.cs index 4c032b5a9..c5c4cbe2c 100644 --- a/src/Areas/Deploy/Commands/GenerateMermaidChart.cs +++ b/src/Areas/Deploy/Commands/GenerateMermaidChart.cs @@ -34,7 +34,7 @@ public static string GenerateChart(string workspaceFolder, AppTopology appTopolo serviceName.Add($"Language: {service.Language}"); serviceName.Add($"Port: {service.Port}"); - if (service.DockerSettings != null && + if (service.DockerSettings != null && string.Equals(service.AzureComputeHost, "azurecontainerapp", StringComparison.OrdinalIgnoreCase)) { serviceName.Add($"DockerFile: {service.DockerSettings.DockerFilePath}"); diff --git a/src/Areas/Deploy/DeploySetup.cs b/src/Areas/Deploy/DeploySetup.cs index c785ab7c1..20e28b38a 100644 --- a/src/Areas/Deploy/DeploySetup.cs +++ b/src/Areas/Deploy/DeploySetup.cs @@ -1,16 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using AzureMcp.Areas.Deploy.Commands; +using AzureMcp.Areas.Deploy.Commands.InfraCodeRules; +using AzureMcp.Areas.Deploy.Commands.Plan; +using AzureMcp.Areas.Deploy.Commands.Quota; +using AzureMcp.Areas.Deploy.Commands.Region; +using AzureMcp.Areas.Deploy.Services; using AzureMcp.Areas.Extension.Commands; using AzureMcp.Commands; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using AzureMcp.Areas.Deploy.Commands; -using AzureMcp.Areas.Deploy.Commands.Region; -using AzureMcp.Areas.Deploy.Commands.Plan; -using AzureMcp.Areas.Deploy.Services; -using AzureMcp.Areas.Deploy.Commands.Quota; -using AzureMcp.Areas.Deploy.Commands.InfraCodeRules; namespace AzureMcp.Areas.Deploy; @@ -31,7 +31,7 @@ public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactor deploy.AddCommand("infra-code-rules-get", new InfraCodeRulesGetCommand(loggerFactory.CreateLogger())); deploy.AddCommand("available-region-get", new RegionCheckCommand(loggerFactory.CreateLogger())); - + deploy.AddCommand("quota-check", new QuotaCheckCommand(loggerFactory.CreateLogger())); deploy.AddCommand("azd-app-log-get", new AzdAppLogGetCommand(loggerFactory.CreateLogger())); diff --git a/src/Areas/Deploy/Options/AppTopology.cs b/src/Areas/Deploy/Options/AppTopology.cs index ba568ead7..57e8c1e20 100644 --- a/src/Areas/Deploy/Options/AppTopology.cs +++ b/src/Areas/Deploy/Options/AppTopology.cs @@ -67,4 +67,4 @@ public class DependencyConfig [JsonPropertyName("environmentVariables")] public string[] EnvironmentVariables { get; set; } = []; -} \ No newline at end of file +} diff --git a/src/Areas/Deploy/Options/RawMcpToolInputOptions.cs b/src/Areas/Deploy/Options/RawMcpToolInputOptions.cs index c3f129b24..32c973f91 100644 --- a/src/Areas/Deploy/Options/RawMcpToolInputOptions.cs +++ b/src/Areas/Deploy/Options/RawMcpToolInputOptions.cs @@ -12,4 +12,4 @@ public class RawMcpToolInputOptions : GlobalOptions { [JsonPropertyName(CommandFactoryToolLoader.RawMcpToolInputOptionName)] public string? RawMcpToolInput { get; set; } -} \ No newline at end of file +} diff --git a/src/Areas/Deploy/Services/Util/AzdAppLogRetriever.cs b/src/Areas/Deploy/Services/Util/AzdAppLogRetriever.cs index 10e9103f8..721dc63e4 100644 --- a/src/Areas/Deploy/Services/Util/AzdAppLogRetriever.cs +++ b/src/Areas/Deploy/Services/Util/AzdAppLogRetriever.cs @@ -1,10 +1,10 @@ +using Azure.Core; using Azure.Monitor.Query; +using Azure.Monitor.Query.Models; using Azure.ResourceManager; -using Azure.ResourceManager.Resources; using Azure.ResourceManager.AppContainers; using Azure.ResourceManager.AppService; -using Azure.Monitor.Query.Models; -using Azure.Core; +using Azure.ResourceManager.Resources; namespace Areas.Deploy.Services.Util; diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/AzureQuotaChecker.cs b/src/Areas/Deploy/Services/Util/QuotaChecker/AzureQuotaChecker.cs index 216a3f4a9..0c634fba7 100644 --- a/src/Areas/Deploy/Services/Util/QuotaChecker/AzureQuotaChecker.cs +++ b/src/Areas/Deploy/Services/Util/QuotaChecker/AzureQuotaChecker.cs @@ -1,8 +1,8 @@ +using System.Net.Http.Headers; +using System.Text.Json; using Azure.Core; using Azure.ResourceManager; using AzureMcp.Services.Azure.Authentication; -using System.Net.Http.Headers; -using System.Text.Json; namespace Areas.Deploy.Services.Util; diff --git a/src/Commands/CommandExtensions.cs b/src/Commands/CommandExtensions.cs index 6292674fb..c3979c7e5 100644 --- a/src/Commands/CommandExtensions.cs +++ b/src/Commands/CommandExtensions.cs @@ -77,7 +77,7 @@ public static ParseResult ParseFromRawMcpToolInput(this Command command, IReadOn var jsonString = jsonObject.ToJsonString(); args.Add(jsonString); } - + return command.Parse(args.ToArray()); } } diff --git a/tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs b/tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs index 7e352e297..e19c3d5bc 100644 --- a/tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs +++ b/tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs @@ -90,4 +90,4 @@ public async Task GenerateArchitectureDiagram_ShouldReturnEncryptedDiagramUrl() Assert.Contains("website", decodedDiagram); Assert.Contains("store", decodedDiagram); } -} \ No newline at end of file +} diff --git a/tests/Areas/Server/UnitTests/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs b/tests/Areas/Server/UnitTests/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs index 8f1983a02..76765e9a5 100644 --- a/tests/Areas/Server/UnitTests/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs +++ b/tests/Areas/Server/UnitTests/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs @@ -306,7 +306,7 @@ public async Task GetsToolsWithRawMcpInputOption() var (toolLoader, commandFactory) = CreateToolLoader(multiServiceOptions); var request = CreateRequest(); var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); - + Assert.NotNull(result); Assert.NotEmpty(result.Tools); @@ -343,7 +343,7 @@ public async Task GetsToolsWithRawMcpInputOption() if (itemsProperty.TryGetProperty("properties", out var servicesProperties)) { var servicePropertyArgs = servicesProperties.EnumerateObject().ToArray(); - Assert.Contains(servicePropertyArgs, prop => prop.Name.Equals("dependencies", StringComparison.OrdinalIgnoreCase) && + Assert.Contains(servicePropertyArgs, prop => prop.Name.Equals("dependencies", StringComparison.OrdinalIgnoreCase) && prop.Value.GetProperty("type").GetString() == "array"); } } From 56a2c399680c98f5bab8880703b984720b6b874f Mon Sep 17 00:00:00 2001 From: xfz11 <81600993+xfz11@users.noreply.github.com> Date: Fri, 18 Jul 2025 13:57:15 +0800 Subject: [PATCH 03/56] rename iac command (#6) * rename iac command * revert changes * update --- ...eRulesGetCommand.cs => IaCRulesGetCommand.cs} | 16 ++++++++-------- src/Areas/Deploy/DeploySetup.cs | 2 +- ...eRulesParameters.cs => IaCRulesParameters.cs} | 0 .../Deploy/Options/DeployOptionDefinitions.cs | 2 +- src/Areas/Deploy/Services/DeployService.cs | 7 ------- ...aCodeRuleRetriever.cs => IaCRuleRetriever.cs} | 2 +- 6 files changed, 11 insertions(+), 18 deletions(-) rename src/Areas/Deploy/Commands/{InfraCodeRulesGetCommand.cs => IaCRulesGetCommand.cs} (84%) rename src/Areas/Deploy/Models/{InfraCodeRulesParameters.cs => IaCRulesParameters.cs} (100%) rename src/Areas/Deploy/Services/Util/{InfraCodeRuleRetriever.cs => IaCRuleRetriever.cs} (98%) diff --git a/src/Areas/Deploy/Commands/InfraCodeRulesGetCommand.cs b/src/Areas/Deploy/Commands/IaCRulesGetCommand.cs similarity index 84% rename from src/Areas/Deploy/Commands/InfraCodeRulesGetCommand.cs rename to src/Areas/Deploy/Commands/IaCRulesGetCommand.cs index f65cd4444..ee1359ee4 100644 --- a/src/Areas/Deploy/Commands/InfraCodeRulesGetCommand.cs +++ b/src/Areas/Deploy/Commands/IaCRulesGetCommand.cs @@ -10,17 +10,17 @@ namespace AzureMcp.Areas.Deploy.Commands.InfraCodeRules; -public sealed class InfraCodeRulesGetCommand(ILogger logger) +public sealed class IaCRulesGetCommand(ILogger logger) : BaseCommand() { - private const string CommandTitle = "Get Infrastructure Code Rules"; - private readonly ILogger _logger = logger; + private const string CommandTitle = "Get Iac(Infrastructure as Code) Rules"; + private readonly ILogger _logger = logger; - private readonly Option _deploymentToolOption = DeployOptionDefinitions.InfraCodeRules.DeploymentTool; - private readonly Option _iacTypeOption = DeployOptionDefinitions.InfraCodeRules.IacType; - private readonly Option _resourceTypesOption = DeployOptionDefinitions.InfraCodeRules.ResourceTypes; + private readonly Option _deploymentToolOption = DeployOptionDefinitions.IaCRules.DeploymentTool; + private readonly Option _iacTypeOption = DeployOptionDefinitions.IaCRules.IacType; + private readonly Option _resourceTypesOption = DeployOptionDefinitions.IaCRules.ResourceTypes; - public override string Name => "infra-code-rules-get"; + public override string Name => "iac-rules-get"; public override string Description => """ @@ -66,7 +66,7 @@ public override Task ExecuteAsync(CommandContext context, Parse .Where(rt => !string.IsNullOrWhiteSpace(rt)) .ToArray(); - List result = InfraCodeRuleRetriever.GetInfraCodeRules( + List result = InfraCodeRuleRetriever.GetIaCRules( options.DeploymentTool, options.IacType, resourceTypes); diff --git a/src/Areas/Deploy/DeploySetup.cs b/src/Areas/Deploy/DeploySetup.cs index 20e28b38a..20a070aa0 100644 --- a/src/Areas/Deploy/DeploySetup.cs +++ b/src/Areas/Deploy/DeploySetup.cs @@ -28,7 +28,7 @@ public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactor deploy.AddCommand("plan-get", new PlanGetCommand(loggerFactory.CreateLogger())); - deploy.AddCommand("infra-code-rules-get", new InfraCodeRulesGetCommand(loggerFactory.CreateLogger())); + deploy.AddCommand("iac-rules-get", new IaCRulesGetCommand(loggerFactory.CreateLogger())); deploy.AddCommand("available-region-get", new RegionCheckCommand(loggerFactory.CreateLogger())); diff --git a/src/Areas/Deploy/Models/InfraCodeRulesParameters.cs b/src/Areas/Deploy/Models/IaCRulesParameters.cs similarity index 100% rename from src/Areas/Deploy/Models/InfraCodeRulesParameters.cs rename to src/Areas/Deploy/Models/IaCRulesParameters.cs diff --git a/src/Areas/Deploy/Options/DeployOptionDefinitions.cs b/src/Areas/Deploy/Options/DeployOptionDefinitions.cs index 9e95250ed..4b28c103b 100644 --- a/src/Areas/Deploy/Options/DeployOptionDefinitions.cs +++ b/src/Areas/Deploy/Options/DeployOptionDefinitions.cs @@ -210,7 +210,7 @@ public static class RegionCheck }; } - public static class InfraCodeRules + public static class IaCRules { public static readonly Option DeploymentTool = new( "--deployment-tool", diff --git a/src/Areas/Deploy/Services/DeployService.cs b/src/Areas/Deploy/Services/DeployService.cs index 7e58e53da..99fe3e9fc 100644 --- a/src/Areas/Deploy/Services/DeployService.cs +++ b/src/Areas/Deploy/Services/DeployService.cs @@ -3,17 +3,10 @@ using System.Diagnostics.CodeAnalysis; using Areas.Deploy.Services.Util; -using Azure; using Azure.Core; -using Azure.Identity; using Azure.ResourceManager; -using Azure.ResourceManager.Resources; using AzureMcp.Areas.Deploy.Models; -using AzureMcp.Helpers; -using AzureMcp.Options; using AzureMcp.Services.Azure; -using AzureMcp.Services.Azure.Subscription; -using Microsoft.Extensions.Logging; namespace AzureMcp.Areas.Deploy.Services; diff --git a/src/Areas/Deploy/Services/Util/InfraCodeRuleRetriever.cs b/src/Areas/Deploy/Services/Util/IaCRuleRetriever.cs similarity index 98% rename from src/Areas/Deploy/Services/Util/InfraCodeRuleRetriever.cs rename to src/Areas/Deploy/Services/Util/IaCRuleRetriever.cs index 83dca39f5..a1b47afc1 100644 --- a/src/Areas/Deploy/Services/Util/InfraCodeRuleRetriever.cs +++ b/src/Areas/Deploy/Services/Util/IaCRuleRetriever.cs @@ -117,7 +117,7 @@ public static void PopulateAppServiceIaCPrompts(string iacType, List llm llmResponse.Add("- App Service must be configured with appropriate settings."); } - public static List GetInfraCodeRules(string deploymentTool, string iacType, string[] resourceTypes) + public static List GetIaCRules(string deploymentTool, string iacType, string[] resourceTypes) { var llmResponse = new List { From 60df1dc908078cc539b5604be8d2830d3cb25f8a Mon Sep 17 00:00:00 2001 From: xfz11 <81600993+xfz11@users.noreply.github.com> Date: Fri, 18 Jul 2025 13:57:40 +0800 Subject: [PATCH 04/56] update deployment plan template --- .../Util/DeploymentPlanTemplateUtil.cs | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs b/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs index c87fa46b1..af74c41b1 100644 --- a/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs +++ b/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs @@ -47,7 +47,7 @@ 1. Verify pods are running and services are exposed { steps.Add($""" 1. Provision Azure Infrastructure - 1. Based on following required Azure resources in plan, get the infra code rules from the tool infra-code-rules-get + 1. Based on following required Azure resources in plan, get the IaC rules from the tool infra-code-rules-get 2. Generate IaC ({azdIacOptions} files) for required azure resources based on the plan. 3. Pre-check: use get_errors tool to check generated Bicep grammar errors. Fix the errors if exist. 4. Run the AZD command `azd provision` to provision the resources and confirm each resource is created or already exists. @@ -106,19 +106,17 @@ 1. Verify command output to ensure the application is deployed successfully : $"Azure Deployment Plan for {projectName} Project"; return $$""" -{Agent should fill in and polish the markdown template below to generate a deployment plan for the project. Then save it to '.azure/plan.copilotmd' file.} +{Agent should fill in and polish the markdown template below to generate a deployment plan for the project. Then save it to '.azure/plan.copilotmd' file. Don't add cost estimation!} #Title: {{title}} ## **Goal** Based on the project to provide a plan to deploy the project to Azure using AZD. It will generate Bicep files and Azure YAML configuration. -## **Execution Step** - -{{string.Join(Environment.NewLine, steps)}} -## **Project Summary** +## **Project Information** { briefly summarize the project structure, services, and configurations, example: +AppName: web - **Technology Stack**: ASP.NET Core 7.0 Razor Pages application - **Application Type**: Task Manager web application with client-side JavaScript - **Containerization**: Ready for deployment with existing Dockerfile @@ -126,12 +124,22 @@ 1. Verify command output to ensure the application is deployed successfully - **Hosting Recommendation**: Azure Container Apps for scalable, serverless container hosting } +## **Azure Resources Architecture** +- **Install the mermaid extension in IDE to view the architecture.** +{a mermaid graph of following recommended azure resource architecture. Only keep the most important edges to make structure clear and readable.} +{ +List how data flows between the services, example: +- The container app gets its image from the Azure Container Registry. +- The container app gets requests and interacts with the Azure SQL Database for data storage and retrieval. +} + + ## **Recommended Azure Resources** Recommended App service hosting the project //agent should fulfill this for each app instance - Application {{projectName}} - Hosting Service Type: {{azureComputeHost}} // it can be Azure Container Apps, Web App Service, Azure Functions, Azure Kubernetes Service. Recommend one based on the project. - - SKU // recommend a sku based on the project, show its cost and performance + - SKU // recommend a sku based on the project, show its performance. Don't estimate the cost. - Configuration: - language: {language} //detect from the project, it can be nodejs, python, dotnet, etc. - dockerFilePath: {dockerFilePath}// fulfill this if service.azureComputeHost is ContainerApp @@ -139,7 +147,7 @@ 1. Verify command output to ensure the application is deployed successfully - Environment Variables: [] // the env variables that are used in the project/required by service - Dependencies Resource - Dependency Name - - SKU // recommend a sku, show its cost and performance + - SKU // recommend a sku, show its performance. - Service Type // it can be Azure SQL, Azure Cosmos DB, Azure Storage, etc. - Connection Type // it can be connection string, managed identity, etc. - Environment Variables: [] // the env variables that are used in the project/required by dependency @@ -150,15 +158,18 @@ Recommended Supporting Services - Key Vault(Optional): If there are dependencies such as postgresql/sql/mysql, create a Key Vault to store connection string. If not, the resource should not show. If there is a Container App, the following resources are required: - Container Registry -- User managed identity: Must be assigned to the container app. -- AcrPull role assignment: User managed identity must have **AcrPull** role ("7f951dda-4ed3-4680-a7ca-43fe172d538d") assigned to the container registry. If there is a WebApp(App Service): - App Service Site Extension (Microsoft.Web/sites/siteextensions): Required for App Service deployments. -- User managed identity: Must have **AcrPull** role ("7f951dda-4ed3-4680-a7ca-43fe172d538d") to the container registry. -## **Azure Resources Architecture** -- **Install the mermaid extension in IDE to view the architecture.** -{a mermaid graph of the deployed resource architecture. Only keep the most important edges to make structure clear and readable.} +Recommended Security Configurations +If there is a Container App +- User managed identity: Must be assigned to the container app. +- AcrPull role assignment: User managed identity must have **AcrPull** role ("7f951dda-4ed3-4680-a7ca-43fe172d538d") assigned to the container registry. + +## **Execution Step** + +{{string.Join(Environment.NewLine, steps)}} + """; } } From 3043492996a42bc815524e8f897e2e8e4c3db416 Mon Sep 17 00:00:00 2001 From: qianwens Date: Mon, 21 Jul 2025 15:02:31 +0800 Subject: [PATCH 05/56] fix comments --- e2eTests/e2eTestPrompts.md | 4 ++-- src/Areas/Deploy/Commands/AzdAppLogGetCommand.cs | 2 -- src/Areas/Deploy/Commands/EncodeMermaid.cs | 4 ++++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/e2eTests/e2eTestPrompts.md b/e2eTests/e2eTestPrompts.md index f6d5773a3..25761d266 100644 --- a/e2eTests/e2eTestPrompts.md +++ b/e2eTests/e2eTestPrompts.md @@ -309,7 +309,7 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | azmcp-deploy-plan-get | Create a plan to deploy this application to azure | | azmcp-deploy-infra-code-rules-get | Show me the rules to generate bicep scripts | | azmcp-deploy-available-region-get | Show me the available regions for these resource types | -| azmcp-deploy-quota-check | Check if there is quota for in region | +| azmcp-deploy-quota-check | Check if there is quota available for in region | | azmcp-deploy-azd-app-log-get | Show me the log of the application deployed by azd | -| azmcp-deploy-cicd-pipeline-guidance-get | How to create cicd pipeline to deploy this app to azure | +| azmcp-deploy-cicd-pipeline-guidance-get | How can I create a CI/CD pipeline to deploy this app to Azure? | | azmcp-deploy-architecture-diagram-generate | Generate the azure architecture diagram for this application | \ No newline at end of file diff --git a/src/Areas/Deploy/Commands/AzdAppLogGetCommand.cs b/src/Areas/Deploy/Commands/AzdAppLogGetCommand.cs index 2ef3e6b30..8cab24e10 100644 --- a/src/Areas/Deploy/Commands/AzdAppLogGetCommand.cs +++ b/src/Areas/Deploy/Commands/AzdAppLogGetCommand.cs @@ -58,8 +58,6 @@ public override async Task ExecuteAsync(CommandContext context, context.Activity?.WithSubscriptionTag(options); - // Parse optional date parameters - var deployService = context.GetService(); string result = await deployService.GetAzdResourceLogsAsync( options.WorkspaceFolder!, diff --git a/src/Areas/Deploy/Commands/EncodeMermaid.cs b/src/Areas/Deploy/Commands/EncodeMermaid.cs index 56c3a0aa1..371d94656 100644 --- a/src/Areas/Deploy/Commands/EncodeMermaid.cs +++ b/src/Areas/Deploy/Commands/EncodeMermaid.cs @@ -6,6 +6,10 @@ namespace AzureMcp.Areas.Deploy.Commands; +/// +/// Utility class to encode and decode Mermaid charts. +/// It compresses the Mermaid chart data to Base64 format for use with https://mermaid.live/view#pako:{encodedDiagram} +/// public static class EncodeMermaid { public static string GetEncodedMermaidChart(string graph) From efd49fda427fd89e47545253f832b5a19b28b42e Mon Sep 17 00:00:00 2001 From: qianwens Date: Wed, 23 Jul 2025 15:04:08 +0800 Subject: [PATCH 06/56] fix the build error and refine the plan template --- .../Services/Util/DeploymentPlanTemplateUtil.cs | 13 ++++++++++--- .../ToolLoading/CommandFactoryToolLoaderTests.cs | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs b/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs index af74c41b1..e35a04b98 100644 --- a/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs +++ b/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs @@ -45,13 +45,20 @@ 1. Verify pods are running and services are exposed if (provisioningTool.ToLowerInvariant() == "azd") { + var deployTitle = targetAppService.ToLowerInvariant() == "aks" + ? "" + : " And Deploy the Application"; + var checkLog = targetAppService.ToLowerInvariant() == "aks" + ? "" + : "6. Check the application log with tool azd-app-log-get to ensure the services are running."; steps.Add($""" - 1. Provision Azure Infrastructure + 1. Provision Azure Infrastructure{deployTitle}: 1. Based on following required Azure resources in plan, get the IaC rules from the tool infra-code-rules-get 2. Generate IaC ({azdIacOptions} files) for required azure resources based on the plan. 3. Pre-check: use get_errors tool to check generated Bicep grammar errors. Fix the errors if exist. - 4. Run the AZD command `azd provision` to provision the resources and confirm each resource is created or already exists. + 4. Run the AZD command `azd up` to provision the resources and confirm each resource is created or already exists. 5. Check the deployment output to ensure the resources are provisioned successfully. + {checkLog} """); if (targetAppService.ToLowerInvariant() == "aks") { @@ -64,7 +71,7 @@ 5. Check the deployment output to ensure the resources are provisioned successfu else { steps.Add($$""" - 3: Summary: + 2: Summary: 1. {{summary}} """); } diff --git a/tests/Areas/Server/UnitTests/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs b/tests/Areas/Server/UnitTests/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs index f1e3c7f07..c874ba8e7 100644 --- a/tests/Areas/Server/UnitTests/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs +++ b/tests/Areas/Server/UnitTests/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs @@ -347,7 +347,7 @@ public async Task GetsToolsWithRawMcpInputOption() } } } - + [Fact] public async Task CallToolHandler_BeforeListToolsHandler_ExecutesSuccessfully() { From 5132545515c8c7e78089835b7de69ebf035c42bd Mon Sep 17 00:00:00 2001 From: qianwens Date: Wed, 23 Jul 2025 17:14:36 +0800 Subject: [PATCH 07/56] refine the template --- .../Services/Util/DeploymentPlanTemplateUtil.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs b/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs index e35a04b98..60eab0e95 100644 --- a/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs +++ b/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs @@ -50,12 +50,12 @@ 1. Verify pods are running and services are exposed : " And Deploy the Application"; var checkLog = targetAppService.ToLowerInvariant() == "aks" ? "" - : "6. Check the application log with tool azd-app-log-get to ensure the services are running."; + : "6. Check the application log with tool `azd-app-log-get` to ensure the services are running."; steps.Add($""" 1. Provision Azure Infrastructure{deployTitle}: - 1. Based on following required Azure resources in plan, get the IaC rules from the tool infra-code-rules-get + 1. Based on following required Azure resources in plan, get the IaC rules from the tool `iac-rules-get` 2. Generate IaC ({azdIacOptions} files) for required azure resources based on the plan. - 3. Pre-check: use get_errors tool to check generated Bicep grammar errors. Fix the errors if exist. + 3. Pre-check: use `get_errors` tool to check generated Bicep grammar errors. Fix the errors if exist. 4. Run the AZD command `azd up` to provision the resources and confirm each resource is created or already exists. 5. Check the deployment output to ensure the resources are provisioned successfully. {checkLog} @@ -113,7 +113,7 @@ 1. Verify command output to ensure the application is deployed successfully : $"Azure Deployment Plan for {projectName} Project"; return $$""" -{Agent should fill in and polish the markdown template below to generate a deployment plan for the project. Then save it to '.azure/plan.copilotmd' file. Don't add cost estimation!} +{Agent should fill in and polish the markdown template below to generate a deployment plan for the project. Then save it to '.azure/plan.copilotmd' file. Don't add cost estimation! Don't add extra validation steps unless it is required! Don't change the tool name!} #Title: {{title}} ## **Goal** @@ -132,7 +132,7 @@ 1. Verify command output to ensure the application is deployed successfully } ## **Azure Resources Architecture** -- **Install the mermaid extension in IDE to view the architecture.** +> **Install the mermaid extension in IDE to view the architecture.** {a mermaid graph of following recommended azure resource architecture. Only keep the most important edges to make structure clear and readable.} { List how data flows between the services, example: @@ -174,7 +174,7 @@ If there is a Container App - AcrPull role assignment: User managed identity must have **AcrPull** role ("7f951dda-4ed3-4680-a7ca-43fe172d538d") assigned to the container registry. ## **Execution Step** - +> **Below are the steps for Copilot to follow; ask Copilot to update or execute this plan.** {{string.Join(Environment.NewLine, steps)}} """; From b4884c69379908bbcb203c578a7684019a2d271d Mon Sep 17 00:00:00 2001 From: xfz11 <81600993+xfz11@users.noreply.github.com> Date: Wed, 23 Jul 2025 09:15:19 +0000 Subject: [PATCH 08/56] add quota group (#9) * add quota group * add unit test * update region checker return message * refactor * add unit test * update * revert --- docs/azmcp-commands.md | 8 +- e2eTests/e2eTestPrompts.md | 8 +- .../Deploy/Commands/DeployJsonContext.cs | 7 - src/Areas/Deploy/DeploySetup.cs | 6 - .../Deploy/{Commands => Models}/Consts.cs | 0 .../{Commands => Models}/EncodeMermaid.cs | 0 .../{Commands => Models}/MermaidData.cs | 0 .../Deploy/Options/DeployOptionDefinitions.cs | 64 --- src/Areas/Deploy/Services/DeployService.cs | 53 --- src/Areas/Deploy/Services/IDeployService.cs | 14 - src/Areas/Quota/Commands/QuotaJsonContext.cs | 21 + .../Commands/RegionCheckCommand.cs | 24 +- .../Commands/UsageCheckCommand.cs} | 36 +- .../Models/CognitiveServiceProperties.cs} | 5 +- .../Quota/Options/QuotaOptionDefinitions.cs | 71 ++++ .../Options/RegionCheckOptions.cs | 2 +- .../Options/UsageCheckOptions.cs} | 4 +- src/Areas/Quota/QuotaSetup.cs | 27 ++ src/Areas/Quota/Services/IQuotaService.cs | 21 + src/Areas/Quota/Services/QuotaService.cs | 64 +++ .../Services/Util/AzureRegionChecker.cs | 14 +- .../Services/Util/AzureUsageChecker.cs} | 56 +-- .../Usage/CognitiveServicesUsageChecker.cs} | 13 +- .../Util/Usage/ComputeUsageChecker.cs} | 13 +- .../Util/Usage/ContainerAppUsageChecker.cs} | 13 +- .../Usage/ContainerInstanceUsageChecker.cs} | 13 +- .../Util/Usage/HDInsightUsageChecker.cs} | 13 +- .../Usage/MachineLearningUsageChecker.cs} | 13 +- .../Util/Usage/NetworkUsageChecker.cs} | 13 +- .../Util/Usage/PostgreSQLUsageChecker.cs} | 13 +- .../Util/Usage/SearchUsageChecker.cs} | 13 +- .../Util/Usage/StorageUsageChecker.cs} | 13 +- .../Deploy/LiveTests/DeployCommandTests.cs | 66 +--- .../UnitTests/AzdAppLogGetCommandTests.cs | 201 ++++++++++ .../UnitTests/IaCRulesGetCommandTests.cs | 182 +++++++++ .../UnitTests/PipelineGenerateCommandTests.cs | 178 +++++++++ .../Deploy/UnitTests/PlanGetCommandTests.cs | 123 ++++++ .../Quota/LiveTests/QuotaCommandTests.cs | 83 ++++ .../UnitTests/RegionCheckCommandTests.cs | 374 ++++++++++++++++++ .../Quota/UnitTests/UsageCheckCommandTests.cs | 273 +++++++++++++ 40 files changed, 1763 insertions(+), 352 deletions(-) rename src/Areas/Deploy/{Commands => Models}/Consts.cs (100%) rename src/Areas/Deploy/{Commands => Models}/EncodeMermaid.cs (100%) rename src/Areas/Deploy/{Commands => Models}/MermaidData.cs (100%) create mode 100644 src/Areas/Quota/Commands/QuotaJsonContext.cs rename src/Areas/{Deploy => Quota}/Commands/RegionCheckCommand.cs (81%) rename src/Areas/{Deploy/Commands/QuotaCheckCommand.cs => Quota/Commands/UsageCheckCommand.cs} (60%) rename src/Areas/{Deploy/Models/AzureRegionCheckParameters.cs => Quota/Models/CognitiveServiceProperties.cs} (67%) create mode 100644 src/Areas/Quota/Options/QuotaOptionDefinitions.cs rename src/Areas/{Deploy => Quota}/Options/RegionCheckOptions.cs (93%) rename src/Areas/{Deploy/Options/QuotaCheckOptions.cs => Quota/Options/UsageCheckOptions.cs} (76%) create mode 100644 src/Areas/Quota/QuotaSetup.cs create mode 100644 src/Areas/Quota/Services/IQuotaService.cs create mode 100644 src/Areas/Quota/Services/QuotaService.cs rename src/Areas/{Deploy => Quota}/Services/Util/AzureRegionChecker.cs (93%) rename src/Areas/{Deploy/Services/Util/QuotaChecker/AzureQuotaChecker.cs => Quota/Services/Util/AzureUsageChecker.cs} (77%) rename src/Areas/{Deploy/Services/Util/QuotaChecker/CognitiveServicesQuotaChecker.cs => Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs} (66%) rename src/Areas/{Deploy/Services/Util/QuotaChecker/ComputeQuotaChecker.cs => Quota/Services/Util/Usage/ComputeUsageChecker.cs} (65%) rename src/Areas/{Deploy/Services/Util/QuotaChecker/ContainerAppQuotaChecker.cs => Quota/Services/Util/Usage/ContainerAppUsageChecker.cs} (64%) rename src/Areas/{Deploy/Services/Util/QuotaChecker/ContainerInstanceQuotaChecker.cs => Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs} (66%) rename src/Areas/{Deploy/Services/Util/QuotaChecker/HDInsightQuotaChecker.cs => Quota/Services/Util/Usage/HDInsightUsageChecker.cs} (65%) rename src/Areas/{Deploy/Services/Util/QuotaChecker/MachineLearningQuotaChecker.cs => Quota/Services/Util/Usage/MachineLearningUsageChecker.cs} (62%) rename src/Areas/{Deploy/Services/Util/QuotaChecker/NetworkQuotaChecker.cs => Quota/Services/Util/Usage/NetworkUsageChecker.cs} (62%) rename src/Areas/{Deploy/Services/Util/QuotaChecker/PostgreSQLQuotaChecker.cs => Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs} (78%) rename src/Areas/{Deploy/Services/Util/QuotaChecker/SearchQuotaChecker.cs => Quota/Services/Util/Usage/SearchUsageChecker.cs} (64%) rename src/Areas/{Deploy/Services/Util/QuotaChecker/StorageQuotaChecker.cs => Quota/Services/Util/Usage/StorageUsageChecker.cs} (65%) create mode 100644 tests/Areas/Deploy/UnitTests/AzdAppLogGetCommandTests.cs create mode 100644 tests/Areas/Deploy/UnitTests/IaCRulesGetCommandTests.cs create mode 100644 tests/Areas/Deploy/UnitTests/PipelineGenerateCommandTests.cs create mode 100644 tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs create mode 100644 tests/Areas/Quota/LiveTests/QuotaCommandTests.cs create mode 100644 tests/Areas/Quota/UnitTests/RegionCheckCommandTests.cs create mode 100644 tests/Areas/Quota/UnitTests/UsageCheckCommandTests.cs diff --git a/docs/azmcp-commands.md b/docs/azmcp-commands.md index 28b7ce175..ec921a5a8 100644 --- a/docs/azmcp-commands.md +++ b/docs/azmcp-commands.md @@ -798,15 +798,15 @@ azmcp extension azqr --subscription \ azmcp bicepschema get --resource-type \ ``` -### Deploy +### Quota ```bash -# Check the Azure quota availability for the resources type -azmcp deploy quota-check --subscription \ +# Check the usage for Azure resources type +azmcp quota usage-get --subscription \ --region \ --resource-types # Get the available regions for the resources types -azmcp deploy available-region-get --subscription \ +azmcp quota available-region-get --subscription \ --resource-types \ [--cognitive-service-model-name ] \ [--cognitive-service-model-version ] \ diff --git a/e2eTests/e2eTestPrompts.md b/e2eTests/e2eTestPrompts.md index 1476c4803..b8c441a81 100644 --- a/e2eTests/e2eTestPrompts.md +++ b/e2eTests/e2eTestPrompts.md @@ -343,8 +343,10 @@ This file contains prompts used for end-to-end testing to ensure each tool is in |:----------|:----------| | azmcp-deploy-plan-get | Create a plan to deploy this application to azure | | azmcp-deploy-infra-code-rules-get | Show me the rules to generate bicep scripts | -| azmcp-deploy-available-region-get | Show me the available regions for these resource types | -| azmcp-deploy-quota-check | Check if there is quota available for in region | | azmcp-deploy-azd-app-log-get | Show me the log of the application deployed by azd | | azmcp-deploy-cicd-pipeline-guidance-get | How can I create a CI/CD pipeline to deploy this app to Azure? | -| azmcp-deploy-architecture-diagram-generate | Generate the azure architecture diagram for this application | \ No newline at end of file +| azmcp-deploy-architecture-diagram-generate | Generate the azure architecture diagram for this application | + +## Quota +| azmcp-quota-available-region-get | Show me the available regions for these resource types | +| azmcp-quota-usage-get | Check usage information for in region | \ No newline at end of file diff --git a/src/Areas/Deploy/Commands/DeployJsonContext.cs b/src/Areas/Deploy/Commands/DeployJsonContext.cs index 914657568..7061f6bcb 100644 --- a/src/Areas/Deploy/Commands/DeployJsonContext.cs +++ b/src/Areas/Deploy/Commands/DeployJsonContext.cs @@ -2,9 +2,6 @@ // Licensed under the MIT License. using System.Text.Json.Serialization; -using Areas.Deploy.Services.Util; -using AzureMcp.Areas.Deploy.Commands.Quota; -using AzureMcp.Areas.Deploy.Commands.Region; using AzureMcp.Areas.Deploy.Models; using AzureMcp.Areas.Deploy.Options; @@ -18,10 +15,6 @@ namespace AzureMcp.Areas.Deploy.Commands; [JsonSerializable(typeof(AppTopology))] [JsonSerializable(typeof(MermaidData))] [JsonSerializable(typeof(MermaidConfig))] -[JsonSerializable(typeof(QuotaCheckCommand.QuotaCheckCommandResult))] -[JsonSerializable(typeof(QuotaInfo))] -[JsonSerializable(typeof(Dictionary>))] -[JsonSerializable(typeof(RegionCheckCommand.RegionCheckCommandResult))] [JsonSerializable(typeof(List))] internal sealed partial class DeployJsonContext : JsonSerializerContext { diff --git a/src/Areas/Deploy/DeploySetup.cs b/src/Areas/Deploy/DeploySetup.cs index 20a070aa0..71e0e77c1 100644 --- a/src/Areas/Deploy/DeploySetup.cs +++ b/src/Areas/Deploy/DeploySetup.cs @@ -4,8 +4,6 @@ using AzureMcp.Areas.Deploy.Commands; using AzureMcp.Areas.Deploy.Commands.InfraCodeRules; using AzureMcp.Areas.Deploy.Commands.Plan; -using AzureMcp.Areas.Deploy.Commands.Quota; -using AzureMcp.Areas.Deploy.Commands.Region; using AzureMcp.Areas.Deploy.Services; using AzureMcp.Areas.Extension.Commands; using AzureMcp.Commands; @@ -30,10 +28,6 @@ public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactor deploy.AddCommand("iac-rules-get", new IaCRulesGetCommand(loggerFactory.CreateLogger())); - deploy.AddCommand("available-region-get", new RegionCheckCommand(loggerFactory.CreateLogger())); - - deploy.AddCommand("quota-check", new QuotaCheckCommand(loggerFactory.CreateLogger())); - deploy.AddCommand("azd-app-log-get", new AzdAppLogGetCommand(loggerFactory.CreateLogger())); deploy.AddCommand("cicd-pipeline-guidance-get", new PipelineGenerateCommand(loggerFactory.CreateLogger())); diff --git a/src/Areas/Deploy/Commands/Consts.cs b/src/Areas/Deploy/Models/Consts.cs similarity index 100% rename from src/Areas/Deploy/Commands/Consts.cs rename to src/Areas/Deploy/Models/Consts.cs diff --git a/src/Areas/Deploy/Commands/EncodeMermaid.cs b/src/Areas/Deploy/Models/EncodeMermaid.cs similarity index 100% rename from src/Areas/Deploy/Commands/EncodeMermaid.cs rename to src/Areas/Deploy/Models/EncodeMermaid.cs diff --git a/src/Areas/Deploy/Commands/MermaidData.cs b/src/Areas/Deploy/Models/MermaidData.cs similarity index 100% rename from src/Areas/Deploy/Commands/MermaidData.cs rename to src/Areas/Deploy/Models/MermaidData.cs diff --git a/src/Areas/Deploy/Options/DeployOptionDefinitions.cs b/src/Areas/Deploy/Options/DeployOptionDefinitions.cs index 4b28c103b..713bf2b6e 100644 --- a/src/Areas/Deploy/Options/DeployOptionDefinitions.cs +++ b/src/Areas/Deploy/Options/DeployOptionDefinitions.cs @@ -146,70 +146,6 @@ public static class PlanGet }; } - public static class QuotaCheck - { - public const string RegionName = "region"; - public const string ResourceTypesName = "resource-types"; - - public static readonly Option Region = new( - $"--{RegionName}", - "The valid Azure region where the resources will be deployed. E.g. 'eastus', 'westus', etc." - ) - { - IsRequired = true - }; - - public static readonly Option ResourceTypes = new( - $"--{ResourceTypesName}", - "The valid Azure resource types that are going to be deployed(comma-separated). E.g. 'Microsoft.App/containerApps,Microsoft.Web/sites,Microsoft.CognitiveServices/accounts', etc." - ) - { - IsRequired = true, - AllowMultipleArgumentsPerToken = true - }; - } - - public static class RegionCheck - { - public const string ResourceTypesName = "resource-types"; - public const string CognitiveServiceModelNameName = "cognitive-service-model-name"; - public const string CognitiveServiceModelVersionName = "cognitive-service-model-version"; - public const string CognitiveServiceDeploymentSkuNameName = "cognitive-service-deployment-sku-name"; - - public static readonly Option ResourceTypes = new( - $"--{ResourceTypesName}", - "Comma-separated list of Azure resource types to check available regions for. The valid Azure resource types. E.g. 'Microsoft.App/containerApps, Microsoft.Web/sites, Microsoft.CognitiveServices/accounts'." - ) - { - IsRequired = true, - AllowMultipleArgumentsPerToken = true - }; - - public static readonly Option CognitiveServiceModelName = new( - $"--{CognitiveServiceModelNameName}", - "Optional model name for cognitive services. Only needed when Microsoft.CognitiveServices is included in resource types." - ) - { - IsRequired = false - }; - - public static readonly Option CognitiveServiceModelVersion = new( - $"--{CognitiveServiceModelVersionName}", - "Optional model version for cognitive services. Only needed when Microsoft.CognitiveServices is included in resource types." - ) - { - IsRequired = false - }; - - public static readonly Option CognitiveServiceDeploymentSkuName = new( - $"--{CognitiveServiceDeploymentSkuNameName}", - "Optional deployment SKU name for cognitive services. Only needed when Microsoft.CognitiveServices is included in resource types." - ) - { - IsRequired = false - }; - } - public static class IaCRules { public static readonly Option DeploymentTool = new( diff --git a/src/Areas/Deploy/Services/DeployService.cs b/src/Areas/Deploy/Services/DeployService.cs index 99fe3e9fc..f1a701aea 100644 --- a/src/Areas/Deploy/Services/DeployService.cs +++ b/src/Areas/Deploy/Services/DeployService.cs @@ -4,8 +4,6 @@ using System.Diagnostics.CodeAnalysis; using Areas.Deploy.Services.Util; using Azure.Core; -using Azure.ResourceManager; -using AzureMcp.Areas.Deploy.Models; using AzureMcp.Services.Azure; namespace AzureMcp.Areas.Deploy.Services; @@ -29,55 +27,4 @@ public async Task GetAzdResourceLogsAsync( limit); return result; } - - public async Task>> GetAzureQuotaAsync( - List resourceTypes, - string subscriptionId, - string location) - { - TokenCredential credential = await GetCredential(); - Dictionary> quotaByResourceTypes = await AzureQuotaService.GetAzureQuotaAsync( - credential, - resourceTypes, - subscriptionId, - location - ); - return quotaByResourceTypes; - } - - public async Task> GetAvailableRegionsForResourceTypesAsync( - string[] resourceTypes, - string subscriptionId, - string? cognitiveServiceModelName = null, - string? cognitiveServiceModelVersion = null, - string? cognitiveServiceDeploymentSkuName = null) - { - ArmClient armClient = await CreateArmClientAsync(); - - // Create cognitive service properties if any of the parameters are provided - CognitiveServiceProperties? cognitiveServiceProperties = null; - if (!string.IsNullOrWhiteSpace(cognitiveServiceModelName) || - !string.IsNullOrWhiteSpace(cognitiveServiceModelVersion) || - !string.IsNullOrWhiteSpace(cognitiveServiceDeploymentSkuName)) - { - cognitiveServiceProperties = new CognitiveServiceProperties - { - ModelName = cognitiveServiceModelName, - ModelVersion = cognitiveServiceModelVersion, - DeploymentSkuName = cognitiveServiceDeploymentSkuName - }; - } - - var availableRegions = await AzureRegionService.GetAvailableRegionsForResourceTypesAsync(armClient, resourceTypes, subscriptionId, cognitiveServiceProperties); - var allRegions = availableRegions.Values - .Where(regions => regions.Count > 0) - .SelectMany(regions => regions) - .Distinct() - .ToList(); - - List commonValidRegions = availableRegions.Values - .Aggregate((current, next) => current.Intersect(next).ToList()); - - return commonValidRegions; - } } diff --git a/src/Areas/Deploy/Services/IDeployService.cs b/src/Areas/Deploy/Services/IDeployService.cs index 6e33a9f2c..7c223c929 100644 --- a/src/Areas/Deploy/Services/IDeployService.cs +++ b/src/Areas/Deploy/Services/IDeployService.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Areas.Deploy.Services.Util; using AzureMcp.Areas.Deploy.Models; using AzureMcp.Options; @@ -14,17 +13,4 @@ Task GetAzdResourceLogsAsync( string azdEnvName, string subscriptionId, int? limit = null); - - Task>> GetAzureQuotaAsync( - List resourceTypes, - string subscriptionId, - string location); - - - Task> GetAvailableRegionsForResourceTypesAsync( - string[] resourceTypes, - string subscriptionId, - string? cognitiveServiceModelName = null, - string? cognitiveServiceModelVersion = null, - string? cognitiveServiceDeploymentSkuName = null); } diff --git a/src/Areas/Quota/Commands/QuotaJsonContext.cs b/src/Areas/Quota/Commands/QuotaJsonContext.cs new file mode 100644 index 000000000..31ce9ee5a --- /dev/null +++ b/src/Areas/Quota/Commands/QuotaJsonContext.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using AzureMcp.Areas.Quota.Services.Util; +using AzureMcp.Areas.Quota.Commands; + +namespace AzureMcp.Areas.Quota.Commands; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull +)] +[JsonSerializable(typeof(UsageCheckCommand.UsageCheckCommandResult))] +[JsonSerializable(typeof(RegionCheckCommand.RegionCheckCommandResult))] +[JsonSerializable(typeof(UsageInfo))] +[JsonSerializable(typeof(Dictionary>))] +internal sealed partial class QuotaJsonContext : JsonSerializerContext +{ +} diff --git a/src/Areas/Deploy/Commands/RegionCheckCommand.cs b/src/Areas/Quota/Commands/RegionCheckCommand.cs similarity index 81% rename from src/Areas/Deploy/Commands/RegionCheckCommand.cs rename to src/Areas/Quota/Commands/RegionCheckCommand.cs index 358761975..355122be2 100644 --- a/src/Areas/Deploy/Commands/RegionCheckCommand.cs +++ b/src/Areas/Quota/Commands/RegionCheckCommand.cs @@ -1,27 +1,25 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Areas.Deploy.Services.Util; -using AzureMcp.Areas.Deploy.Models; -using AzureMcp.Areas.Deploy.Options; -using AzureMcp.Areas.Deploy.Services; +using AzureMcp.Areas.Quota.Options; +using AzureMcp.Areas.Quota.Services; using AzureMcp.Commands; using AzureMcp.Commands.Subscription; using AzureMcp.Models.Command; using AzureMcp.Services.Telemetry; using Microsoft.Extensions.Logging; -namespace AzureMcp.Areas.Deploy.Commands.Region; +namespace AzureMcp.Areas.Quota.Commands; public sealed class RegionCheckCommand(ILogger logger) : SubscriptionCommand() { - private const string CommandTitle = "Get Available Azure Regions"; + private const string CommandTitle = "Get available regions for Azure resource types"; private readonly ILogger _logger = logger; - private readonly Option _resourceTypesOption = DeployOptionDefinitions.RegionCheck.ResourceTypes; - private readonly Option _cognitiveServiceModelNameOption = DeployOptionDefinitions.RegionCheck.CognitiveServiceModelName; - private readonly Option _cognitiveServiceModelVersionOption = DeployOptionDefinitions.RegionCheck.CognitiveServiceModelVersion; - private readonly Option _cognitiveServiceDeploymentSkuNameOption = DeployOptionDefinitions.RegionCheck.CognitiveServiceDeploymentSkuName; + private readonly Option _resourceTypesOption = QuotaOptionDefinitions.RegionCheck.ResourceTypes; + private readonly Option _cognitiveServiceModelNameOption = QuotaOptionDefinitions.RegionCheck.CognitiveServiceModelName; + private readonly Option _cognitiveServiceModelVersionOption = QuotaOptionDefinitions.RegionCheck.CognitiveServiceModelVersion; + private readonly Option _cognitiveServiceDeploymentSkuNameOption = QuotaOptionDefinitions.RegionCheck.CognitiveServiceDeploymentSkuName; public override string Name => "available-region-get"; @@ -78,8 +76,8 @@ public override async Task ExecuteAsync(CommandContext context, throw new ArgumentException("Resource types cannot be empty.", nameof(options.ResourceTypes)); } - var deployService = context.GetService(); - List toolResult = await deployService.GetAvailableRegionsForResourceTypesAsync( + var quotaService = context.GetService(); + List toolResult = await quotaService.GetAvailableRegionsForResourceTypesAsync( resourceTypes, options.Subscription!, options.CognitiveServiceModelName, @@ -91,7 +89,7 @@ public override async Task ExecuteAsync(CommandContext context, context.Response.Results = toolResult?.Count > 0 ? ResponseResult.Create( new RegionCheckCommandResult(toolResult), - DeployJsonContext.Default.RegionCheckCommandResult) : + QuotaJsonContext.Default.RegionCheckCommandResult) : null; } catch (Exception ex) diff --git a/src/Areas/Deploy/Commands/QuotaCheckCommand.cs b/src/Areas/Quota/Commands/UsageCheckCommand.cs similarity index 60% rename from src/Areas/Deploy/Commands/QuotaCheckCommand.cs rename to src/Areas/Quota/Commands/UsageCheckCommand.cs index e8b2f0f0e..493daf734 100644 --- a/src/Areas/Deploy/Commands/QuotaCheckCommand.cs +++ b/src/Areas/Quota/Commands/UsageCheckCommand.cs @@ -1,30 +1,30 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Areas.Deploy.Services.Util; -using AzureMcp.Areas.Deploy.Options; -using AzureMcp.Areas.Deploy.Services; +using AzureMcp.Areas.Quota.Services.Util; +using AzureMcp.Areas.Quota.Options; +using AzureMcp.Areas.Quota.Services; using AzureMcp.Commands; using AzureMcp.Commands.Subscription; using AzureMcp.Models.Command; using AzureMcp.Services.Telemetry; using Microsoft.Extensions.Logging; -namespace AzureMcp.Areas.Deploy.Commands.Quota; +namespace AzureMcp.Areas.Quota.Commands; -public class QuotaCheckCommand(ILogger logger) : SubscriptionCommand() +public class UsageCheckCommand(ILogger logger) : SubscriptionCommand() { - private const string CommandTitle = "Check Available Azure Quota for Regions"; - private readonly ILogger _logger = logger; + private const string CommandTitle = "Check Azure resources usage and quota in a region"; + private readonly ILogger _logger = logger; - private readonly Option _regionOption = DeployOptionDefinitions.QuotaCheck.Region; - private readonly Option _resourceTypesOption = DeployOptionDefinitions.QuotaCheck.ResourceTypes; + private readonly Option _regionOption = QuotaOptionDefinitions.QuotaCheck.Region; + private readonly Option _resourceTypesOption = QuotaOptionDefinitions.QuotaCheck.ResourceTypes; - public override string Name => "quota-check"; + public override string Name => "usage-get"; public override string Description => """ - This tool will check the Azure quota availability for the resources that are going to be deployed. + This tool will check the usage and quota information for Azure resources in a region. """; public override string Title => CommandTitle; @@ -36,7 +36,7 @@ protected override void RegisterOptions(Command command) command.AddOption(_resourceTypesOption); } - protected override QuotaCheckOptions BindOptions(ParseResult parseResult) + protected override UsageCheckOptions BindOptions(ParseResult parseResult) { var options = base.BindOptions(parseResult); options.Region = parseResult.GetValueForOption(_regionOption) ?? string.Empty; @@ -64,8 +64,8 @@ public override async Task ExecuteAsync(CommandContext context, .Select(rt => rt.Trim()) .Where(rt => !string.IsNullOrWhiteSpace(rt)) .ToList(); - var deployService = context.GetService(); - Dictionary> toolResult = await deployService.GetAzureQuotaAsync( + var quotaService = context.GetService(); + Dictionary> toolResult = await quotaService.GetAzureQuotaAsync( ResourceTypes, options.Subscription!, options.Region); @@ -74,19 +74,19 @@ public override async Task ExecuteAsync(CommandContext context, context.Response.Results = toolResult?.Count > 0 ? ResponseResult.Create( - new QuotaCheckCommandResult(toolResult), - DeployJsonContext.Default.QuotaCheckCommandResult) : + new UsageCheckCommandResult(toolResult), + QuotaJsonContext.Default.UsageCheckCommandResult) : null; } catch (Exception ex) { - _logger.LogError(ex, "Error checking Azure quota"); + _logger.LogError(ex, "Error checking Azure resource usage"); HandleException(context, ex); } return context.Response; } - internal record QuotaCheckCommandResult(Dictionary> QuotaInfo); + internal record UsageCheckCommandResult(Dictionary> UsageInfo); } diff --git a/src/Areas/Deploy/Models/AzureRegionCheckParameters.cs b/src/Areas/Quota/Models/CognitiveServiceProperties.cs similarity index 67% rename from src/Areas/Deploy/Models/AzureRegionCheckParameters.cs rename to src/Areas/Quota/Models/CognitiveServiceProperties.cs index fcdf57e0f..c0cafb50e 100644 --- a/src/Areas/Deploy/Models/AzureRegionCheckParameters.cs +++ b/src/Areas/Quota/Models/CognitiveServiceProperties.cs @@ -1,4 +1,7 @@ -namespace AzureMcp.Areas.Deploy.Models; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AzureMcp.Areas.Quota.Models; public class CognitiveServiceProperties { diff --git a/src/Areas/Quota/Options/QuotaOptionDefinitions.cs b/src/Areas/Quota/Options/QuotaOptionDefinitions.cs new file mode 100644 index 000000000..21d0de1f6 --- /dev/null +++ b/src/Areas/Quota/Options/QuotaOptionDefinitions.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AzureMcp.Areas.Quota.Options; + +public static class QuotaOptionDefinitions +{ + public static class QuotaCheck + { + public const string RegionName = "region"; + public const string ResourceTypesName = "resource-types"; + + public static readonly Option Region = new( + $"--{RegionName}", + "The valid Azure region where the resources will be deployed. E.g. 'eastus', 'westus', etc." + ) + { + IsRequired = true + }; + + public static readonly Option ResourceTypes = new( + $"--{ResourceTypesName}", + "The valid Azure resource types that are going to be deployed(comma-separated). E.g. 'Microsoft.App/containerApps,Microsoft.Web/sites,Microsoft.CognitiveServices/accounts', etc." + ) + { + IsRequired = true, + AllowMultipleArgumentsPerToken = true + }; + } + + public static class RegionCheck + { + public const string ResourceTypesName = "resource-types"; + public const string CognitiveServiceModelNameName = "cognitive-service-model-name"; + public const string CognitiveServiceModelVersionName = "cognitive-service-model-version"; + public const string CognitiveServiceDeploymentSkuNameName = "cognitive-service-deployment-sku-name"; + + public static readonly Option ResourceTypes = new( + $"--{ResourceTypesName}", + "Comma-separated list of Azure resource types to check available regions for. The valid Azure resource types. E.g. 'Microsoft.App/containerApps, Microsoft.Web/sites, Microsoft.CognitiveServices/accounts'." + ) + { + IsRequired = true, + AllowMultipleArgumentsPerToken = true + }; + + public static readonly Option CognitiveServiceModelName = new( + $"--{CognitiveServiceModelNameName}", + "Optional model name for cognitive services. Only needed when Microsoft.CognitiveServices is included in resource types." + ) + { + IsRequired = false + }; + + public static readonly Option CognitiveServiceModelVersion = new( + $"--{CognitiveServiceModelVersionName}", + "Optional model version for cognitive services. Only needed when Microsoft.CognitiveServices is included in resource types." + ) + { + IsRequired = false + }; + + public static readonly Option CognitiveServiceDeploymentSkuName = new( + $"--{CognitiveServiceDeploymentSkuNameName}", + "Optional deployment SKU name for cognitive services. Only needed when Microsoft.CognitiveServices is included in resource types." + ) + { + IsRequired = false + }; + } +} diff --git a/src/Areas/Deploy/Options/RegionCheckOptions.cs b/src/Areas/Quota/Options/RegionCheckOptions.cs similarity index 93% rename from src/Areas/Deploy/Options/RegionCheckOptions.cs rename to src/Areas/Quota/Options/RegionCheckOptions.cs index b1ee4e799..ebe9d8e48 100644 --- a/src/Areas/Deploy/Options/RegionCheckOptions.cs +++ b/src/Areas/Quota/Options/RegionCheckOptions.cs @@ -4,7 +4,7 @@ using System.Text.Json.Serialization; using AzureMcp.Options; -namespace AzureMcp.Areas.Deploy.Options; +namespace AzureMcp.Areas.Quota.Options; public sealed class RegionCheckOptions : SubscriptionOptions { diff --git a/src/Areas/Deploy/Options/QuotaCheckOptions.cs b/src/Areas/Quota/Options/UsageCheckOptions.cs similarity index 76% rename from src/Areas/Deploy/Options/QuotaCheckOptions.cs rename to src/Areas/Quota/Options/UsageCheckOptions.cs index ec012c0f1..1fdfe93f4 100644 --- a/src/Areas/Deploy/Options/QuotaCheckOptions.cs +++ b/src/Areas/Quota/Options/UsageCheckOptions.cs @@ -4,9 +4,9 @@ using System.Text.Json.Serialization; using AzureMcp.Options; -namespace AzureMcp.Areas.Deploy.Options; +namespace AzureMcp.Areas.Quota.Options; -public sealed class QuotaCheckOptions : SubscriptionOptions +public sealed class UsageCheckOptions : SubscriptionOptions { [JsonPropertyName("region")] public string Region { get; set; } = string.Empty; diff --git a/src/Areas/Quota/QuotaSetup.cs b/src/Areas/Quota/QuotaSetup.cs new file mode 100644 index 000000000..1ca3a507b --- /dev/null +++ b/src/Areas/Quota/QuotaSetup.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AzureMcp.Areas.Quota.Commands; +using AzureMcp.Areas.Quota.Services; +using AzureMcp.Commands; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace AzureMcp.Areas.Quota; + +internal sealed class QuotaSetup : IAreaSetup +{ + public void ConfigureServices(IServiceCollection services) + { + services.AddTransient(); + } + + public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactory) + { + var quota = new CommandGroup("quota", "Quota commands for getting available region for Azure resources or getting usage for Azure resource per region"); + rootGroup.AddSubGroup(quota); + + quota.AddCommand("usage-get", new UsageCheckCommand(loggerFactory.CreateLogger())); + quota.AddCommand("available-region-get", new RegionCheckCommand(loggerFactory.CreateLogger())); + } +} diff --git a/src/Areas/Quota/Services/IQuotaService.cs b/src/Areas/Quota/Services/IQuotaService.cs new file mode 100644 index 000000000..a7fe99225 --- /dev/null +++ b/src/Areas/Quota/Services/IQuotaService.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AzureMcp.Areas.Quota.Services.Util; + +namespace AzureMcp.Areas.Quota.Services; + +public interface IQuotaService +{ + Task>> GetAzureQuotaAsync( + List resourceTypes, + string subscriptionId, + string location); + + Task> GetAvailableRegionsForResourceTypesAsync( + string[] resourceTypes, + string subscriptionId, + string? cognitiveServiceModelName = null, + string? cognitiveServiceModelVersion = null, + string? cognitiveServiceDeploymentSkuName = null); +} diff --git a/src/Areas/Quota/Services/QuotaService.cs b/src/Areas/Quota/Services/QuotaService.cs new file mode 100644 index 000000000..cca24f945 --- /dev/null +++ b/src/Areas/Quota/Services/QuotaService.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.ResourceManager; +using AzureMcp.Areas.Quota.Models; +using AzureMcp.Areas.Quota.Services.Util; +using AzureMcp.Services.Azure; + +namespace AzureMcp.Areas.Quota.Services; + +public class QuotaService() : BaseAzureService, IQuotaService +{ + public async Task>> GetAzureQuotaAsync( + List resourceTypes, + string subscriptionId, + string location) + { + TokenCredential credential = await GetCredential(); + Dictionary> quotaByResourceTypes = await AzureQuotaService.GetAzureQuotaAsync( + credential, + resourceTypes, + subscriptionId, + location + ); + return quotaByResourceTypes; + } + + public async Task> GetAvailableRegionsForResourceTypesAsync( + string[] resourceTypes, + string subscriptionId, + string? cognitiveServiceModelName = null, + string? cognitiveServiceModelVersion = null, + string? cognitiveServiceDeploymentSkuName = null) + { + ArmClient armClient = await CreateArmClientAsync(); + + // Create cognitive service properties if any of the parameters are provided + CognitiveServiceProperties? cognitiveServiceProperties = null; + if (!string.IsNullOrWhiteSpace(cognitiveServiceModelName) || + !string.IsNullOrWhiteSpace(cognitiveServiceModelVersion) || + !string.IsNullOrWhiteSpace(cognitiveServiceDeploymentSkuName)) + { + cognitiveServiceProperties = new CognitiveServiceProperties + { + ModelName = cognitiveServiceModelName, + ModelVersion = cognitiveServiceModelVersion, + DeploymentSkuName = cognitiveServiceDeploymentSkuName + }; + } + + var availableRegions = await AzureRegionService.GetAvailableRegionsForResourceTypesAsync(armClient, resourceTypes, subscriptionId, cognitiveServiceProperties); + var allRegions = availableRegions.Values + .Where(regions => regions.Count > 0) + .SelectMany(regions => regions) + .Distinct() + .ToList(); + + List commonValidRegions = availableRegions.Values + .Aggregate((current, next) => current.Intersect(next).ToList()); + + return commonValidRegions; + } +} diff --git a/src/Areas/Deploy/Services/Util/AzureRegionChecker.cs b/src/Areas/Quota/Services/Util/AzureRegionChecker.cs similarity index 93% rename from src/Areas/Deploy/Services/Util/AzureRegionChecker.cs rename to src/Areas/Quota/Services/Util/AzureRegionChecker.cs index 67516749a..266c490b8 100644 --- a/src/Areas/Deploy/Services/Util/AzureRegionChecker.cs +++ b/src/Areas/Quota/Services/Util/AzureRegionChecker.cs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using Azure; using Azure.Core; using Azure.ResourceManager; @@ -5,9 +8,9 @@ using Azure.ResourceManager.CognitiveServices.Models; using Azure.ResourceManager.PostgreSql.FlexibleServers; using Azure.ResourceManager.PostgreSql.FlexibleServers.Models; -using AzureMcp.Areas.Deploy.Models; +using AzureMcp.Areas.Quota.Models; -namespace Areas.Deploy.Services.Util; +namespace AzureMcp.Areas.Quota.Services.Util; public interface IRegionChecker { @@ -59,8 +62,7 @@ public override async Task> GetAvailableRegionsAsync(string resourc } catch (Exception error) { - Console.WriteLine($"Error fetching regions for resource type {resourceType}: {error.Message}"); - return []; + throw new Exception($"Error fetching regions for resource type {resourceType}: {error.Message}"); } } } @@ -130,7 +132,7 @@ public override async Task> GetAvailableRegionsAsync(string resourc } catch (Exception error) { - Console.WriteLine($"Error checking cognitive services models for region {region}: {error.Message}"); + throw new Exception($"Error checking cognitive services models for region {region}: {error.Message}"); } } @@ -172,7 +174,7 @@ public override async Task> GetAvailableRegionsAsync(string resourc } catch (Exception error) { - Console.WriteLine($"Error checking PostgreSQL capabilities for region {region}: {error.Message}"); + throw new Exception($"Error checking PostgreSQL capabilities for region {region}: {error.Message}"); } } diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/AzureQuotaChecker.cs b/src/Areas/Quota/Services/Util/AzureUsageChecker.cs similarity index 77% rename from src/Areas/Deploy/Services/Util/QuotaChecker/AzureQuotaChecker.cs rename to src/Areas/Quota/Services/Util/AzureUsageChecker.cs index 0c634fba7..06b97e32d 100644 --- a/src/Areas/Deploy/Services/Util/QuotaChecker/AzureQuotaChecker.cs +++ b/src/Areas/Quota/Services/Util/AzureUsageChecker.cs @@ -4,7 +4,7 @@ using Azure.ResourceManager; using AzureMcp.Services.Azure.Authentication; -namespace Areas.Deploy.Services.Util; +namespace AzureMcp.Areas.Quota.Services.Util; // For simplicity, we currently apply a single rule for all Azure resource providers: // - Any resource provider not listed in the enum is treated as having no quota limitations. @@ -27,7 +27,7 @@ public enum ResourceProvider ContainerInstance } -public record QuotaInfo( +public record UsageInfo( string Name, int Limit, int Used, @@ -35,13 +35,13 @@ public record QuotaInfo( string? Description = null ); -public interface IQuotaChecker +public interface IUsageChecker { - Task> GetQuotaForLocationAsync(string location); + Task> GetQuotaForLocationAsync(string location); } // Abstract base class for checking Azure quotas -public abstract class AzureQuotaChecker : IQuotaChecker +public abstract class AzureUsageChecker : IUsageChecker { protected readonly string SubscriptionId; protected readonly ArmClient ResourceClient; @@ -49,14 +49,14 @@ public abstract class AzureQuotaChecker : IQuotaChecker protected readonly TokenCredential Credential; private static readonly HttpClient HttpClient = new(); - protected AzureQuotaChecker(TokenCredential credential, string subscriptionId) + protected AzureUsageChecker(TokenCredential credential, string subscriptionId) { SubscriptionId = subscriptionId; Credential = credential ?? throw new ArgumentNullException(nameof(credential)); ResourceClient = new ArmClient(credential, subscriptionId); } - public abstract Task> GetQuotaForLocationAsync(string location); + public abstract Task> GetQuotaForLocationAsync(string location); protected async Task GetQuotaByUrlAsync(string requestUrl) { @@ -86,8 +86,8 @@ protected AzureQuotaChecker(TokenCredential credential, string subscriptionId) } } -// Factory function to create quota checkers -public static class QuotaCheckerFactory +// Factory function to create usage checkers +public static class UsageCheckerFactory { private static readonly Dictionary ProviderMapping = new() { @@ -103,7 +103,7 @@ public static class QuotaCheckerFactory { "Microsoft.ContainerInstance", ResourceProvider.ContainerInstance } }; - public static IQuotaChecker CreateQuotaChecker(TokenCredential credential, string provider, string subscriptionId) + public static IUsageChecker CreateUsageChecker(TokenCredential credential, string provider, string subscriptionId) { if (!ProviderMapping.TryGetValue(provider, out var resourceProvider)) { @@ -112,16 +112,16 @@ public static IQuotaChecker CreateQuotaChecker(TokenCredential credential, strin return resourceProvider switch { - ResourceProvider.Compute => new ComputeQuotaChecker(credential, subscriptionId), - ResourceProvider.CognitiveServices => new CognitiveServicesQuotaChecker(credential, subscriptionId), - ResourceProvider.Storage => new StorageQuotaChecker(credential, subscriptionId), - ResourceProvider.ContainerApp => new ContainerAppQuotaChecker(credential, subscriptionId), - ResourceProvider.Network => new NetworkQuotaChecker(credential, subscriptionId), - ResourceProvider.MachineLearning => new MachineLearningQuotaChecker(credential, subscriptionId), - ResourceProvider.PostgreSQL => new PostgreSQLQuotaChecker(credential, subscriptionId), - ResourceProvider.HDInsight => new HDInsightQuotaChecker(credential, subscriptionId), - ResourceProvider.Search => new SearchQuotaChecker(credential, subscriptionId), - ResourceProvider.ContainerInstance => new ContainerInstanceQuotaChecker(credential, subscriptionId), + ResourceProvider.Compute => new ComputeUsageChecker(credential, subscriptionId), + ResourceProvider.CognitiveServices => new CognitiveServicesUsageChecker(credential, subscriptionId), + ResourceProvider.Storage => new StorageUsageChecker(credential, subscriptionId), + ResourceProvider.ContainerApp => new ContainerAppUsageChecker(credential, subscriptionId), + ResourceProvider.Network => new NetworkUsageChecker(credential, subscriptionId), + ResourceProvider.MachineLearning => new MachineLearningUsageChecker(credential, subscriptionId), + ResourceProvider.PostgreSQL => new PostgreSQLUsageChecker(credential, subscriptionId), + ResourceProvider.HDInsight => new HDInsightUsageChecker(credential, subscriptionId), + ResourceProvider.Search => new SearchUsageChecker(credential, subscriptionId), + ResourceProvider.ContainerInstance => new ContainerInstanceUsageChecker(credential, subscriptionId), _ => throw new ArgumentException($"No implementation for provider: {provider}") }; } @@ -130,7 +130,7 @@ public static IQuotaChecker CreateQuotaChecker(TokenCredential credential, strin // Service to get Azure quota for a list of resource types public static class AzureQuotaService { - public static async Task>> GetAzureQuotaAsync( + public static async Task>> GetAzureQuotaAsync( TokenCredential credential, List resourceTypes, string subscriptionId, @@ -147,24 +147,24 @@ public static async Task>> GetAzureQuotaAsync var (provider, resourceTypesForProvider) = (kvp.Key, kvp.Value); try { - var quotaChecker = QuotaCheckerFactory.CreateQuotaChecker(credential, provider, subscriptionId); - var quotaInfo = await quotaChecker.GetQuotaForLocationAsync(location); + var usageChecker = UsageCheckerFactory.CreateUsageChecker(credential, provider, subscriptionId); + var quotaInfo = await usageChecker.GetQuotaForLocationAsync(location); Console.WriteLine($"Quota info for provider {provider}: {quotaInfo.Count} items"); - return resourceTypesForProvider.Select(rt => new KeyValuePair>(rt, quotaInfo)); + return resourceTypesForProvider.Select(rt => new KeyValuePair>(rt, quotaInfo)); } catch (ArgumentException ex) when (ex.Message.Contains("Unsupported resource provider", StringComparison.OrdinalIgnoreCase)) { - return resourceTypesForProvider.Select(rt => new KeyValuePair>(rt, new List(){ - new QuotaInfo(rt, 0, 0, Description: "No Limit") + return resourceTypesForProvider.Select(rt => new KeyValuePair>(rt, new List(){ + new UsageInfo(rt, 0, 0, Description: "No Limit") })); } catch (Exception error) { Console.WriteLine($"Error fetching quota for provider {provider}: {error.Message}"); - return resourceTypesForProvider.Select(rt => new KeyValuePair>(rt, new List() + return resourceTypesForProvider.Select(rt => new KeyValuePair>(rt, new List() { - new QuotaInfo(rt, 0, 0, Description: error.Message) + new UsageInfo(rt, 0, 0, Description: error.Message) })); } }); diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/CognitiveServicesQuotaChecker.cs b/src/Areas/Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs similarity index 66% rename from src/Areas/Deploy/Services/Util/QuotaChecker/CognitiveServicesQuotaChecker.cs rename to src/Areas/Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs index 9edb158c1..101ca8cb8 100644 --- a/src/Areas/Deploy/Services/Util/QuotaChecker/CognitiveServicesQuotaChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs @@ -2,21 +2,21 @@ using Azure.ResourceManager.CognitiveServices; using Azure.ResourceManager.CognitiveServices.Models; -namespace Areas.Deploy.Services.Util; +namespace AzureMcp.Areas.Quota.Services.Util; -public class CognitiveServicesQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +public class CognitiveServicesUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetQuotaForLocationAsync(string location) { try { var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); var usages = subscription.GetUsagesAsync(location); - var result = new List(); + var result = new List(); await foreach (ServiceAccountUsage item in usages) { - result.Add(new QuotaInfo( + result.Add(new UsageInfo( Name: item.Name?.LocalizedValue ?? item.Name?.Value ?? string.Empty, Limit: (int)(item.Limit ?? 0), Used: (int)(item.CurrentValue ?? 0), @@ -29,8 +29,7 @@ public override async Task> GetQuotaForLocationAsync(string loca } catch (Exception error) { - Console.WriteLine($"Error fetching cognitive services quotas: {error.Message}"); - return []; + throw new Exception($"Error fetching cognitive services quotas: {error.Message}"); } } } diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/ComputeQuotaChecker.cs b/src/Areas/Quota/Services/Util/Usage/ComputeUsageChecker.cs similarity index 65% rename from src/Areas/Deploy/Services/Util/QuotaChecker/ComputeQuotaChecker.cs rename to src/Areas/Quota/Services/Util/Usage/ComputeUsageChecker.cs index 983d210d8..624c2d6ed 100644 --- a/src/Areas/Deploy/Services/Util/QuotaChecker/ComputeQuotaChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/ComputeUsageChecker.cs @@ -3,21 +3,21 @@ using Azure.ResourceManager.Compute; using Azure.ResourceManager.Compute.Models; -namespace Areas.Deploy.Services.Util; +namespace AzureMcp.Areas.Quota.Services.Util; -public class ComputeQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +public class ComputeUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetQuotaForLocationAsync(string location) { try { var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); var usages = subscription.GetUsagesAsync(location); - var result = new List(); + var result = new List(); await foreach (ComputeUsage item in usages) { - result.Add(new QuotaInfo( + result.Add(new UsageInfo( Name: item.Name?.LocalizedValue ?? item.Name?.Value ?? string.Empty, Limit: (int)item.Limit, Used: (int)item.CurrentValue, @@ -29,8 +29,7 @@ public override async Task> GetQuotaForLocationAsync(string loca } catch (Exception error) { - Console.WriteLine($"Error fetching compute quotas: {error.Message}"); - return []; + throw new Exception($"Error fetching compute quotas: {error.Message}"); } } } diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/ContainerAppQuotaChecker.cs b/src/Areas/Quota/Services/Util/Usage/ContainerAppUsageChecker.cs similarity index 64% rename from src/Areas/Deploy/Services/Util/QuotaChecker/ContainerAppQuotaChecker.cs rename to src/Areas/Quota/Services/Util/Usage/ContainerAppUsageChecker.cs index 17dffe220..b62645617 100644 --- a/src/Areas/Deploy/Services/Util/QuotaChecker/ContainerAppQuotaChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/ContainerAppUsageChecker.cs @@ -2,21 +2,21 @@ using Azure.ResourceManager.AppContainers; using Azure.ResourceManager.AppContainers.Models; -namespace Areas.Deploy.Services.Util; +namespace AzureMcp.Areas.Quota.Services.Util; -public class ContainerAppQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +public class ContainerAppUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetQuotaForLocationAsync(string location) { try { var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); var usages = subscription.GetUsagesAsync(location); - var result = new List(); + var result = new List(); await foreach (var item in usages) { - result.Add(new QuotaInfo( + result.Add(new UsageInfo( Name: item.Name?.Value ?? string.Empty, Limit: (int)(item.Limit), Used: (int)(item.CurrentValue), @@ -28,8 +28,7 @@ public override async Task> GetQuotaForLocationAsync(string loca } catch (Exception error) { - Console.WriteLine($"Error fetching Container Apps quotas: {error.Message}"); - return []; + throw new Exception($"Error fetching Container Apps quotas: {error.Message}"); } } } diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/ContainerInstanceQuotaChecker.cs b/src/Areas/Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs similarity index 66% rename from src/Areas/Deploy/Services/Util/QuotaChecker/ContainerInstanceQuotaChecker.cs rename to src/Areas/Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs index f94376fa2..9881ce99a 100644 --- a/src/Areas/Deploy/Services/Util/QuotaChecker/ContainerInstanceQuotaChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs @@ -2,21 +2,21 @@ using Azure.ResourceManager.ContainerInstance; using Azure.ResourceManager.ContainerInstance.Models; -namespace Areas.Deploy.Services.Util; +namespace AzureMcp.Areas.Quota.Services.Util; -public class ContainerInstanceQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +public class ContainerInstanceUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetQuotaForLocationAsync(string location) { try { var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); var usages = subscription.GetUsagesWithLocationAsync(location); - var result = new List(); + var result = new List(); await foreach (ContainerInstanceUsage item in usages) { - result.Add(new QuotaInfo( + result.Add(new UsageInfo( Name: item.Name?.LocalizedValue ?? item.Name?.Value ?? string.Empty, Limit: (int)(item.Limit ?? 0), Used: (int)(item.CurrentValue ?? 0), @@ -28,8 +28,7 @@ public override async Task> GetQuotaForLocationAsync(string loca } catch (Exception error) { - Console.WriteLine($"Error fetching Container Instance quotas: {error.Message}"); - return []; + throw new Exception($"Error fetching Container Instance quotas: {error.Message}"); } } } diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/HDInsightQuotaChecker.cs b/src/Areas/Quota/Services/Util/Usage/HDInsightUsageChecker.cs similarity index 65% rename from src/Areas/Deploy/Services/Util/QuotaChecker/HDInsightQuotaChecker.cs rename to src/Areas/Quota/Services/Util/Usage/HDInsightUsageChecker.cs index fa59a90c1..2fe97ac3a 100644 --- a/src/Areas/Deploy/Services/Util/QuotaChecker/HDInsightQuotaChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/HDInsightUsageChecker.cs @@ -2,21 +2,21 @@ using Azure.ResourceManager.HDInsight; using Azure.ResourceManager.HDInsight.Models; -namespace Areas.Deploy.Services.Util; +namespace AzureMcp.Areas.Quota.Services.Util; -public class HDInsightQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +public class HDInsightUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetQuotaForLocationAsync(string location) { try { var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); var usages = subscription.GetHDInsightUsagesAsync(location); - var result = new List(); + var result = new List(); await foreach (HDInsightUsage item in usages) { - result.Add(new QuotaInfo( + result.Add(new UsageInfo( Name: item.Name?.LocalizedValue ?? item.Name?.Value ?? string.Empty, Limit: (int)(item.Limit ?? 0), Used: (int)(item.CurrentValue ?? 0), @@ -27,8 +27,7 @@ public override async Task> GetQuotaForLocationAsync(string loca } catch (Exception error) { - Console.WriteLine($"Error fetching HDInsight quotas: {error.Message}"); - return []; + throw new Exception($"Error fetching HDInsight quotas: {error.Message}"); } } } diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/MachineLearningQuotaChecker.cs b/src/Areas/Quota/Services/Util/Usage/MachineLearningUsageChecker.cs similarity index 62% rename from src/Areas/Deploy/Services/Util/QuotaChecker/MachineLearningQuotaChecker.cs rename to src/Areas/Quota/Services/Util/Usage/MachineLearningUsageChecker.cs index 6cd1e1fad..e23658a8e 100644 --- a/src/Areas/Deploy/Services/Util/QuotaChecker/MachineLearningQuotaChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/MachineLearningUsageChecker.cs @@ -1,21 +1,21 @@ using Azure.Core; using Azure.ResourceManager.MachineLearning; -namespace Areas.Deploy.Services.Util; +namespace AzureMcp.Areas.Quota.Services.Util; -public class MachineLearningQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +public class MachineLearningUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetQuotaForLocationAsync(string location) { try { var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); var usages = subscription.GetMachineLearningUsagesAsync(location); - var result = new List(); + var result = new List(); await foreach (var item in usages) { - result.Add(new QuotaInfo( + result.Add(new UsageInfo( Name: item.Name?.Value ?? string.Empty, Limit: (int)(item.Limit ?? 0), Used: (int)(item.CurrentValue ?? 0), @@ -27,8 +27,7 @@ public override async Task> GetQuotaForLocationAsync(string loca } catch (Exception error) { - Console.WriteLine($"Error fetching Machine Learning Services quotas: {error.Message}"); - return []; + throw new Exception($"Error fetching Machine Learning Services quotas: {error.Message}"); } } } diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/NetworkQuotaChecker.cs b/src/Areas/Quota/Services/Util/Usage/NetworkUsageChecker.cs similarity index 62% rename from src/Areas/Deploy/Services/Util/QuotaChecker/NetworkQuotaChecker.cs rename to src/Areas/Quota/Services/Util/Usage/NetworkUsageChecker.cs index e8644a79d..1d342d39a 100644 --- a/src/Areas/Deploy/Services/Util/QuotaChecker/NetworkQuotaChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/NetworkUsageChecker.cs @@ -1,21 +1,21 @@ using Azure.Core; using Azure.ResourceManager.Network; -namespace Areas.Deploy.Services.Util; +namespace AzureMcp.Areas.Quota.Services.Util; -public class NetworkQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +public class NetworkUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetQuotaForLocationAsync(string location) { try { var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); var usages = subscription.GetUsagesAsync(location); - var result = new List(); + var result = new List(); await foreach (var item in usages) { - result.Add(new QuotaInfo( + result.Add(new UsageInfo( Name: item.Name?.Value ?? string.Empty, Limit: (int)(item.Limit), Used: (int)(item.CurrentValue), @@ -27,8 +27,7 @@ public override async Task> GetQuotaForLocationAsync(string loca } catch (Exception error) { - Console.WriteLine($"Error fetching network quotas: {error.Message}"); - return []; + throw new Exception($"Error fetching network quotas: {error.Message}"); } } } diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/PostgreSQLQuotaChecker.cs b/src/Areas/Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs similarity index 78% rename from src/Areas/Deploy/Services/Util/QuotaChecker/PostgreSQLQuotaChecker.cs rename to src/Areas/Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs index fc13200b3..9fd5fd288 100644 --- a/src/Areas/Deploy/Services/Util/QuotaChecker/PostgreSQLQuotaChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs @@ -1,11 +1,11 @@ using Areas.Server.Commands.Tools.DeployTools.Util; using Azure.Core; -namespace Areas.Deploy.Services.Util; +namespace AzureMcp.Areas.Quota.Services.Util; -public class PostgreSQLQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +public class PostgreSQLUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetQuotaForLocationAsync(string location) { try { @@ -17,7 +17,7 @@ public override async Task> GetQuotaForLocationAsync(string loca return []; } - var result = new List(); + var result = new List(); foreach (var item in valueElement.EnumerateArray()) { var name = string.Empty; @@ -45,15 +45,14 @@ public override async Task> GetQuotaForLocationAsync(string loca unit = unitElement.GetStringSafe(); } - result.Add(new QuotaInfo(name, limit, used, unit)); + result.Add(new UsageInfo(name, limit, used, unit)); } return result; } catch (Exception error) { - Console.WriteLine($"Error fetching PostgreSQL quotas: {error.Message}"); - return []; + throw new Exception($"Error fetching PostgreSQL quotas: {error.Message}"); } } } diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/SearchQuotaChecker.cs b/src/Areas/Quota/Services/Util/Usage/SearchUsageChecker.cs similarity index 64% rename from src/Areas/Deploy/Services/Util/QuotaChecker/SearchQuotaChecker.cs rename to src/Areas/Quota/Services/Util/Usage/SearchUsageChecker.cs index aab2356b5..db700a4d6 100644 --- a/src/Areas/Deploy/Services/Util/QuotaChecker/SearchQuotaChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/SearchUsageChecker.cs @@ -2,21 +2,21 @@ using Azure.ResourceManager.Search; using Azure.ResourceManager.Search.Models; -namespace Areas.Deploy.Services.Util; +namespace AzureMcp.Areas.Quota.Services.Util; -public class SearchQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +public class SearchUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetQuotaForLocationAsync(string location) { try { var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); var usages = subscription.GetUsagesBySubscriptionAsync(location); - var result = new List(); + var result = new List(); await foreach (QuotaUsageResult item in usages) { - result.Add(new QuotaInfo( + result.Add(new UsageInfo( Name: item.Name?.Value ?? string.Empty, Limit: item.Limit ?? 0, Used: item.CurrentValue ?? 0, @@ -28,8 +28,7 @@ public override async Task> GetQuotaForLocationAsync(string loca } catch (Exception error) { - Console.WriteLine($"Error fetching Search quotas: {error.Message}"); - return []; + throw new Exception($"Error fetching Search quotas: {error.Message}"); } } } diff --git a/src/Areas/Deploy/Services/Util/QuotaChecker/StorageQuotaChecker.cs b/src/Areas/Quota/Services/Util/Usage/StorageUsageChecker.cs similarity index 65% rename from src/Areas/Deploy/Services/Util/QuotaChecker/StorageQuotaChecker.cs rename to src/Areas/Quota/Services/Util/Usage/StorageUsageChecker.cs index 0bbcf5136..83a7a50f0 100644 --- a/src/Areas/Deploy/Services/Util/QuotaChecker/StorageQuotaChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/StorageUsageChecker.cs @@ -3,21 +3,21 @@ using Azure.ResourceManager.Storage; using Azure.ResourceManager.Storage.Models; -namespace Areas.Deploy.Services.Util; +namespace AzureMcp.Areas.Quota.Services.Util; -public class StorageQuotaChecker(TokenCredential credential, string subscriptionId) : AzureQuotaChecker(credential, subscriptionId) +public class StorageUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetQuotaForLocationAsync(string location) { try { var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")); var usages = subscription.GetUsagesByLocationAsync(location); - var result = new List(); + var result = new List(); await foreach (var item in usages) { - result.Add(new QuotaInfo( + result.Add(new UsageInfo( Name: item.Name?.Value ?? string.Empty, Limit: item.Limit ?? 0, Used: item.CurrentValue ?? 0, @@ -29,8 +29,7 @@ public override async Task> GetQuotaForLocationAsync(string loca } catch (Exception error) { - Console.WriteLine($"Error fetching storage quotas: {error.Message}"); - return []; + throw new Exception($"Error fetching storage quotas: {error.Message}"); } } } diff --git a/tests/Areas/Deploy/LiveTests/DeployCommandTests.cs b/tests/Areas/Deploy/LiveTests/DeployCommandTests.cs index 0e4c1b3c1..10c0b4386 100644 --- a/tests/Areas/Deploy/LiveTests/DeployCommandTests.cs +++ b/tests/Areas/Deploy/LiveTests/DeployCommandTests.cs @@ -33,34 +33,15 @@ public async Task Should_get_plan() new() { { "workspace-folder", "C:/" }, - { "project-name", "django" } + { "project-name", "django" }, + { "target-app-service", "ContainerApp" }, + { "provisioning-tool", "AZD" }, + { "azd-iac-options", "bicep" } }); // assert Assert.StartsWith(result, "Title:"); } - [Fact] - [Trait("Category", "Live")] - public async Task Should_check_azure_quota() - { - JsonElement? result = await CallToolAsync( - "azmcp-deploy-quota-check", - new() { - { "subscription", _subscriptionId }, - { "region", "eastus" }, - { "resource-types", "Microsoft.App, Microsoft.Storage/storageAccounts" } - }); - // assert - var quotas = result.AssertProperty("quotaInfo"); - Assert.Equal(JsonValueKind.Object, quotas.ValueKind); - var appQuotas = quotas.AssertProperty("Microsoft.App"); - Assert.Equal(JsonValueKind.Array, appQuotas.ValueKind); - Assert.NotEmpty(appQuotas.EnumerateArray()); - var storageQuotas = quotas.AssertProperty("Microsoft.Storage/storageAccounts"); - Assert.Equal(JsonValueKind.Array, storageQuotas.ValueKind); - Assert.NotEmpty(storageQuotas.EnumerateArray()); - } - [Fact] [Trait("Category", "Live")] public async Task Should_get_infrastructure_code_rules() @@ -161,45 +142,6 @@ public async Task Should_get_azd_app_logs() Assert.StartsWith("App logs retrieved:", result); } - [Fact] - [Trait("Category", "Live")] - public async Task Should_check_azure_regions() - { - // act - var result = await CallToolAsync( - "azmcp-deploy-region-check", - new() - { - { "subscription", _subscriptionId }, - { "resource-types", "Microsoft.Web/sites, Microsoft.Storage/storageAccounts" }, - }); - - // assert - var availableRegions = result.AssertProperty("availableRegions"); - Assert.Equal(JsonValueKind.Array, availableRegions.ValueKind); - Assert.NotEmpty(availableRegions.EnumerateArray()); - } - - [Fact] - [Trait("Category", "Live")] - public async Task Should_check_regions_with_cognitive_services() - { - // act - var result = await CallToolAsync( - "azmcp-deploy-region-check", - new() - { - { "subscription", _subscriptionId }, - { "resource-types", "Microsoft.CognitiveServices/accounts" }, - { "cognitive-service-model-name", "gpt-4o" }, - { "cognitive-service-deployment-sku-name", "Standard" } - }); - - // assert - var availableRegions = result.AssertProperty("availableRegions"); - Assert.Equal(JsonValueKind.Array, availableRegions.ValueKind); - Assert.NotEmpty(availableRegions.EnumerateArray()); - } private async Task CallToolMessageAsync(string command, Dictionary parameters) { diff --git a/tests/Areas/Deploy/UnitTests/AzdAppLogGetCommandTests.cs b/tests/Areas/Deploy/UnitTests/AzdAppLogGetCommandTests.cs new file mode 100644 index 000000000..7b935ac37 --- /dev/null +++ b/tests/Areas/Deploy/UnitTests/AzdAppLogGetCommandTests.cs @@ -0,0 +1,201 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Parsing; +using AzureMcp.Areas.Deploy.Commands; +using AzureMcp.Areas.Deploy.Services; +using AzureMcp.Models.Command; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace AzureMcp.Tests.Areas.Deploy.UnitTests; + +[Trait("Area", "Deploy")] +public class AzdAppLogGetCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly IDeployService _deployService; + private readonly Parser _parser; + private readonly CommandContext _context; + private readonly AzdAppLogGetCommand _command; + + public AzdAppLogGetCommandTests() + { + _logger = Substitute.For>(); + _deployService = Substitute.For(); + + var collection = new ServiceCollection(); + collection.AddSingleton(_deployService); + _serviceProvider = collection.BuildServiceProvider(); + _context = new(_serviceProvider); + _command = new(_logger); + _parser = new(_command.GetCommand()); + } + + [Fact] + public async Task Should_get_azd_app_logs() + { + // arrange + var expectedLogs = "App logs retrieved:\n[2024-01-01 10:00:00] Application started\n[2024-01-01 10:01:00] Processing request"; + _deployService.GetAzdResourceLogsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedLogs); + + var args = _parser.Parse([ + "--subscription", "test-subscription-id", + "--workspace-folder", "C:/Users/", + "--azd-env-name", "dotnet-demo", + "--limit", "10" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.StartsWith("App logs retrieved:", result.Message); + Assert.Contains("Application started", result.Message); + + } + + [Fact] + public async Task Should_get_azd_app_logs_with_default_limit() + { + // arrange + var expectedLogs = "App logs retrieved:\nSample log entry"; + _deployService.GetAzdResourceLogsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedLogs); + + var args = _parser.Parse([ + "--subscription", "test-subscription-id", + "--workspace-folder", "C:/project", + "--azd-env-name", "my-env" + // No limit specified - should use default + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.StartsWith("App logs retrieved:", result.Message); + } + + [Fact] + public async Task Should_handle_no_logs_found() + { + // arrange + _deployService.GetAzdResourceLogsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns("No logs found."); + + var args = _parser.Parse([ + "--subscription", "test-subscription-id", + "--workspace-folder", "C:/empty-project", + "--azd-env-name", "empty-env", + "--limit", "50" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.Equal("No logs found.", result.Message); + } + + [Fact] + public async Task Should_handle_error_during_log_retrieval() + { + // arrange + var errorMessage = "Error during retrieval of app logs of azd project:\nNo resource group with tag {\"azd-env-name\": test-env} found."; + _deployService.GetAzdResourceLogsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(errorMessage); + + var args = _parser.Parse([ + "--subscription", "test-subscription-id", + "--workspace-folder", "C:/invalid-project", + "--azd-env-name", "test-env" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.StartsWith("Error during retrieval of app logs", result.Message); + Assert.Contains("test-env", result.Message); + } + + [Fact] + public async Task Should_handle_service_exception() + { + // arrange + _deployService.GetAzdResourceLogsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Failed to connect to Azure")); + + var args = _parser.Parse([ + "--subscription", "test-subscription-id", + "--workspace-folder", "C:/project", + "--azd-env-name", "test-env" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.NotEqual(200, result.Status); // Should be an error status + Assert.NotNull(result.Message); + Assert.Contains("Failed to connect to Azure", result.Message); + } + + [Fact] + public async Task Should_validate_required_parameters() + { + // arrange - missing required workspace-folder parameter + var args = _parser.Parse([ + "--subscription", "test-subscription-id", + "--azd-env-name", "test-env" + // Missing workspace-folder + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.NotEqual(200, result.Status); // Should fail validation + } + + +} diff --git a/tests/Areas/Deploy/UnitTests/IaCRulesGetCommandTests.cs b/tests/Areas/Deploy/UnitTests/IaCRulesGetCommandTests.cs new file mode 100644 index 000000000..8773dd040 --- /dev/null +++ b/tests/Areas/Deploy/UnitTests/IaCRulesGetCommandTests.cs @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Parsing; +using AzureMcp.Areas.Deploy.Commands.InfraCodeRules; +using AzureMcp.Models.Command; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace AzureMcp.Tests.Areas.Deploy.UnitTests; + +[Trait("Area", "Deploy")] +public class IaCRulesGetCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly Parser _parser; + private readonly CommandContext _context; + private readonly IaCRulesGetCommand _command; + + public IaCRulesGetCommandTests() + { + _logger = Substitute.For>(); + + var collection = new ServiceCollection(); + _serviceProvider = collection.BuildServiceProvider(); + _context = new(_serviceProvider); + _command = new(_logger); + _parser = new(_command.GetCommand()); + } + + [Fact] + public async Task Should_get_infrastructure_code_rules() + { + // arrange + var args = _parser.Parse([ + "--deployment-tool", "azd", + "--iac-type", "bicep", + "--resource-types", "appservice, azurestorage" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains("Deployment Tool azd rules", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Should_get_infrastructure_rules_for_terraform() + { + // arrange + var args = _parser.Parse([ + "--deployment-tool", "azd", + "--iac-type", "terraform", + "--resource-types", "containerapp, azurecosmosdb" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains("Expected parameters in terraform parameters", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Should_get_infrastructure_rules_for_function_app() + { + // arrange + var args = _parser.Parse([ + "--deployment-tool", "azd", + "--iac-type", "bicep", + "--resource-types", "function" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains("Additional requirements for Function Apps", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Storage Blob Data Owner", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Should_get_infrastructure_rules_for_container_app() + { + // arrange + var args = _parser.Parse([ + "--deployment-tool", "azd", + "--iac-type", "bicep", + "--resource-types", "containerapp" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains("Additional requirements for Container Apps", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("mcr.microsoft.com/azuredocs/containerapps-helloworld:latest", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Should_get_infrastructure_rules_for_azcli_deployment_tool() + { + // arrange + var args = _parser.Parse([ + "--deployment-tool", "AzCli", + "--iac-type", "bicep", + "--resource-types", "appservice" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains("Deployment Tool AzCli", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("No additional rules", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Should_include_necessary_tools_in_response() + { + // arrange + var args = _parser.Parse([ + "--deployment-tool", "azd", + "--iac-type", "terraform", + "--resource-types", "containerapp" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains("Tools needed:", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("az cli", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("azd", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("docker", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Should_handle_multiple_resource_types() + { + // arrange + var args = _parser.Parse([ + "--deployment-tool", "azd", + "--iac-type", "bicep", + "--resource-types", "appservice,containerapp,function" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains("Resources: appservice, containerapp, function", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("App Service Rules", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Additional requirements for Container Apps", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Additional requirements for Function Apps", result.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/Areas/Deploy/UnitTests/PipelineGenerateCommandTests.cs b/tests/Areas/Deploy/UnitTests/PipelineGenerateCommandTests.cs new file mode 100644 index 000000000..c9397902f --- /dev/null +++ b/tests/Areas/Deploy/UnitTests/PipelineGenerateCommandTests.cs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Parsing; +using AzureMcp.Areas.Deploy.Commands; +using AzureMcp.Models.Command; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace AzureMcp.Tests.Areas.Deploy.UnitTests; + +[Trait("Area", "Deploy")] +public class PipelineGenerateCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly Parser _parser; + private readonly CommandContext _context; + private readonly PipelineGenerateCommand _command; + + public PipelineGenerateCommandTests() + { + _logger = Substitute.For>(); + + var collection = new ServiceCollection(); + _serviceProvider = collection.BuildServiceProvider(); + _context = new(_serviceProvider); + _command = new(_logger); + _parser = new(_command.GetCommand()); + } + + [Fact] + public async Task Should_generate_pipeline() + { + // arrange + var args = _parser.Parse([ + "--subscription", "test-subscription-id", + "--use-azd-pipeline-config", "true" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains("Run \"azd pipeline config\" to help the user create a deployment pipeline.", result.Message); + } + + [Fact] + public async Task Should_generate_pipeline_with_github_details() + { + // arrange + var args = _parser.Parse([ + "--subscription", "test-subscription-id", + "--use-azd-pipeline-config", "false", + "--organization-name", "test-org", + "--repository-name", "test-repo", + "--github-environment-name", "production" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains("Help the user to set up a CI/CD pipeline", result.Message); + Assert.Contains("test-org", result.Message); + Assert.Contains("test-repo", result.Message); + Assert.Contains("production", result.Message); + } + + [Fact] + public async Task Should_generate_pipeline_with_default_azd_pipeline_config() + { + // arrange - not providing use-azd-pipeline-config should default to false + var args = _parser.Parse([ + "--subscription", "test-subscription-id" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains("Help the user to set up a CI/CD pipeline", result.Message); + Assert.Contains("Github Actions workflow", result.Message); + } + + [Fact] + public async Task Should_generate_pipeline_with_minimal_github_info() + { + // arrange + var args = _parser.Parse([ + "--subscription", "test-subscription-id", + "--use-azd-pipeline-config", "false" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains("Help the user to set up a CI/CD pipeline", result.Message); + Assert.Contains("{$organization-of-repo}", result.Message); + Assert.Contains("{$repository-name}", result.Message); + Assert.Contains("dev", result.Message); // default environment + } + + [Fact] + public async Task Should_handle_guid_subscription_id() + { + // arrange + var guidSubscriptionId = "12345678-1234-1234-1234-123456789abc"; + var args = _parser.Parse([ + "--subscription", guidSubscriptionId, + "--use-azd-pipeline-config", "false" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains($"User is deploying to subscription {guidSubscriptionId}", result.Message); + } + + [Fact] + public async Task Should_handle_non_guid_subscription_id() + { + // arrange + var args = _parser.Parse([ + "--subscription", "my-subscription-name", + "--use-azd-pipeline-config", "false" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains("az account show --query id -o tsv", result.Message); + } + + [Fact] + public async Task Should_include_service_principal_creation_steps() + { + // arrange + var args = _parser.Parse([ + "--subscription", "test-subscription-id", + "--use-azd-pipeline-config", "false" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains("az ad sp create-for-rbac", result.Message); + Assert.Contains("federated-credential create", result.Message); + Assert.Contains("gh secret set", result.Message); + } +} diff --git a/tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs b/tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs new file mode 100644 index 000000000..f8a9c43a9 --- /dev/null +++ b/tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Parsing; +using AzureMcp.Areas.Deploy.Commands.Plan; +using AzureMcp.Models.Command; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace AzureMcp.Tests.Areas.Deploy.UnitTests; + +[Trait("Area", "Deploy")] +public class PlanGetCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly Parser _parser; + private readonly CommandContext _context; + private readonly PlanGetCommand _command; + + public PlanGetCommandTests() + { + _logger = Substitute.For>(); + + var collection = new ServiceCollection(); + _serviceProvider = collection.BuildServiceProvider(); + _context = new(_serviceProvider); + _command = new(_logger); + _parser = new(_command.GetCommand()); + } + + [Fact] + public async Task GetPlan_Should_Return_Expected_Result() + { + // arrange + var args = _parser.Parse([ + "--workspace-folder", "C:/", + "--project-name", "django", + "--target-app-service", "ContainerApp", + "--provisioning-tool", "AZD", + "--azd-iac-options", "bicep" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains("Title: Azure Deployment Plan for django Project", result.Message); + Assert.Contains("Azure Container Apps", result.Message); + } + + [Fact] + public async Task Should_get_plan_with_default_iac_options() + { + // arrange + var args = _parser.Parse([ + "--workspace-folder", "C:/test", + "--project-name", "myapp", + "--target-app-service", "WebApp", + "--provisioning-tool", "azd" + // No azd-iac-options provided - should default to "bicep" + ]); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains("Title: Azure Deployment Plan for myapp Project", result.Message); + Assert.Contains("Azure Web App Service", result.Message); + } + + [Fact] + public async Task Should_get_plan_for_kubernetes() + { + // arrange + var args = _parser.Parse([ + "--workspace-folder", "C:/k8s-project", + "--project-name", "k8s-app", + "--target-app-service", "AKS", + "--provisioning-tool", "terraform" + ]); + var context = new CommandContext(_serviceProvider); + + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains("Title: Azure Deployment Plan for k8s-app Project", result.Message); + Assert.Contains("Azure Kubernetes Service", result.Message); + } + + [Fact] + public async Task Should_get_plan_with_default_target_service() + { + // arrange + var args = _parser.Parse([ + "--workspace-folder", "C:/", + "--project-name", "default-app", + "--target-app-service", "unknown-service", // This should default to Container Apps + "--provisioning-tool", "AZD" + ]); + // act + var result = await _command.ExecuteAsync(_context, args); + + // assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Message); + Assert.Contains("Title: Azure Deployment Plan for default-app Project", result.Message); + Assert.Contains("Azure Container Apps", result.Message); // Should default to Container Apps + } +} diff --git a/tests/Areas/Quota/LiveTests/QuotaCommandTests.cs b/tests/Areas/Quota/LiveTests/QuotaCommandTests.cs new file mode 100644 index 000000000..203611721 --- /dev/null +++ b/tests/Areas/Quota/LiveTests/QuotaCommandTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using AzureMcp.Tests.Client; +using AzureMcp.Tests.Client.Helpers; +using Xunit; + +namespace AzureMcp.Tests.Areas.Quota.LiveTests; + +[Trait("Area", "Quota")] +public class QuotaCommandTests : CommandTestsBase, + IClassFixture +{ + private readonly string _subscriptionId; + + public QuotaCommandTests(LiveTestFixture liveTestFixture, ITestOutputHelper output) : base(liveTestFixture, output) + { + _subscriptionId = Settings.SubscriptionId; + } + + [Fact] + [Trait("Category", "Live")] + public async Task Should_check_azure_quota() + { + JsonElement? result = await CallToolAsync( + "azmcp-quota-usage-get", + new() { + { "subscription", _subscriptionId }, + { "region", "eastus" }, + { "resource-types", "Microsoft.App, Microsoft.Storage/storageAccounts" } + }); + // assert + var quotas = result.AssertProperty("usageInfo"); + Assert.Equal(JsonValueKind.Object, quotas.ValueKind); + var appQuotas = quotas.AssertProperty("Microsoft.App"); + Assert.Equal(JsonValueKind.Array, appQuotas.ValueKind); + Assert.NotEmpty(appQuotas.EnumerateArray()); + var storageQuotas = quotas.AssertProperty("Microsoft.Storage/storageAccounts"); + Assert.Equal(JsonValueKind.Array, storageQuotas.ValueKind); + Assert.NotEmpty(storageQuotas.EnumerateArray()); + } + + [Fact] + [Trait("Category", "Live")] + public async Task Should_check_azure_regions() + { + // act + var result = await CallToolAsync( + "azmcp-quota-available-region-get", + new() + { + { "subscription", _subscriptionId }, + { "resource-types", "Microsoft.Web/sites, Microsoft.Storage/storageAccounts" }, + }); + + // assert + var availableRegions = result.AssertProperty("availableRegions"); + Assert.Equal(JsonValueKind.Array, availableRegions.ValueKind); + Assert.NotEmpty(availableRegions.EnumerateArray()); + } + + [Fact] + [Trait("Category", "Live")] + public async Task Should_check_regions_with_cognitive_services() + { + // act + var result = await CallToolAsync( + "azmcp-quota-available-region-get", + new() + { + { "subscription", _subscriptionId }, + { "resource-types", "Microsoft.CognitiveServices/accounts" }, + { "cognitive-service-model-name", "gpt-4o" }, + { "cognitive-service-deployment-sku-name", "Standard" } + }); + + // assert + var availableRegions = result.AssertProperty("availableRegions"); + Assert.Equal(JsonValueKind.Array, availableRegions.ValueKind); + Assert.NotEmpty(availableRegions.EnumerateArray()); + } +} diff --git a/tests/Areas/Quota/UnitTests/RegionCheckCommandTests.cs b/tests/Areas/Quota/UnitTests/RegionCheckCommandTests.cs new file mode 100644 index 000000000..4fd9678e0 --- /dev/null +++ b/tests/Areas/Quota/UnitTests/RegionCheckCommandTests.cs @@ -0,0 +1,374 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Parsing; +using System.Text.Json; +using AzureMcp.Areas.Quota.Commands; +using AzureMcp.Areas.Quota.Services; +using AzureMcp.Models.Command; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace AzureMcp.Tests.Areas.Quota.UnitTests; + +[Trait("Area", "Quota")] +public sealed class RegionCheckCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IQuotaService _quotaService; + private readonly ILogger _logger; + private readonly RegionCheckCommand _command; + private readonly Parser _parser; + + public RegionCheckCommandTests() + { + _quotaService = Substitute.For(); + _logger = Substitute.For>(); + + var services = new ServiceCollection(); + services.AddSingleton(_quotaService); + _serviceProvider = services.BuildServiceProvider(); + + _command = new RegionCheckCommand(_logger); + _parser = new Parser(_command.GetCommand()); + } + + [Fact] + public async Task Should_check_azure_regions_success() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var resourceTypes = "Microsoft.Web/sites, Microsoft.Storage/storageAccounts"; + + var expectedRegions = new List + { + "eastus", + "westus", + "eastus2", + "westus2", + "centralus" + }; + + _quotaService.GetAvailableRegionsForResourceTypesAsync( + Arg.Is(array => + array.Length == 2 && + array.Contains("Microsoft.Web/sites") && + array.Contains("Microsoft.Storage/storageAccounts")), + subscriptionId, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedRegions); + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Results); + + // Verify the service was called with the correct parameters + await _quotaService.Received(1).GetAvailableRegionsForResourceTypesAsync( + Arg.Is(array => + array.Length == 2 && + array.Contains("Microsoft.Web/sites") && + array.Contains("Microsoft.Storage/storageAccounts")), + subscriptionId, + null, + null, + null); + + // Verify the response structure + var json = JsonSerializer.Serialize(result.Results); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + var response = JsonSerializer.Deserialize(json, options); + Assert.NotNull(response); + Assert.NotNull(response.AvailableRegions); + Assert.Equal(5, response.AvailableRegions.Count); + + // Verify the expected regions are returned + Assert.Contains("eastus", response.AvailableRegions); + Assert.Contains("westus", response.AvailableRegions); + Assert.Contains("eastus2", response.AvailableRegions); + Assert.Contains("westus2", response.AvailableRegions); + Assert.Contains("centralus", response.AvailableRegions); + } + + [Fact] + public async Task Should_check_regions_with_cognitive_services_success() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var resourceTypes = "Microsoft.CognitiveServices/accounts"; + var cognitiveServiceModelName = "gpt-4o"; + var cognitiveServiceDeploymentSkuName = "Standard"; + + var expectedRegions = new List + { + "eastus", + "westus2", + "northcentralus" + }; + + _quotaService.GetAvailableRegionsForResourceTypesAsync( + Arg.Is(array => + array.Length == 1 && + array.Contains("Microsoft.CognitiveServices/accounts")), + subscriptionId, + cognitiveServiceModelName, + Arg.Any(), + cognitiveServiceDeploymentSkuName) + .Returns(expectedRegions); + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--resource-types", resourceTypes, + "--cognitive-service-model-name", cognitiveServiceModelName, + "--cognitive-service-deployment-sku-name", cognitiveServiceDeploymentSkuName + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Results); + + // Verify the service was called with the correct parameters + await _quotaService.Received(1).GetAvailableRegionsForResourceTypesAsync( + Arg.Is(array => + array.Length == 1 && + array.Contains("Microsoft.CognitiveServices/accounts")), + subscriptionId, + cognitiveServiceModelName, + null, + cognitiveServiceDeploymentSkuName); + + // Verify the response structure + var json = JsonSerializer.Serialize(result.Results); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + var response = JsonSerializer.Deserialize(json, options); + Assert.NotNull(response); + Assert.NotNull(response.AvailableRegions); + Assert.Equal(3, response.AvailableRegions.Count); + + // Verify the expected regions are returned + Assert.Contains("eastus", response.AvailableRegions); + Assert.Contains("westus2", response.AvailableRegions); + Assert.Contains("northcentralus", response.AvailableRegions); + } + + [Fact] + public async Task Should_ReturnError_empty_resource_types() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var resourceTypes = ""; + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(400, result.Status); + Assert.Contains("Missing Required options: --resource-types", result.Message); + + // Verify the service was not called + await _quotaService.DidNotReceive().GetAvailableRegionsForResourceTypesAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Should_handle_service_exception() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var resourceTypes = "Microsoft.Web/sites"; + var expectedException = new Exception("Service error occurred"); + + _quotaService.GetAvailableRegionsForResourceTypesAsync( + Arg.Any(), + subscriptionId, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(expectedException); + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(500, result.Status); + Assert.Contains("Service error occurred", result.Message); + } + + [Fact] + public async Task Should_parse_multiple_resource_types_with_spaces() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var resourceTypes = " Microsoft.Web/sites , Microsoft.Storage/storageAccounts , Microsoft.Compute/virtualMachines "; + + var expectedRegions = new List { "eastus", "westus2" }; + + _quotaService.GetAvailableRegionsForResourceTypesAsync( + Arg.Is(array => + array.Length == 3 && + array.Contains("Microsoft.Web/sites") && + array.Contains("Microsoft.Storage/storageAccounts") && + array.Contains("Microsoft.Compute/virtualMachines")), + subscriptionId, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedRegions); + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + + // Verify the service was called with correctly parsed resource types + await _quotaService.Received(1).GetAvailableRegionsForResourceTypesAsync( + Arg.Is(array => + array.Length == 3 && + array.Contains("Microsoft.Web/sites") && + array.Contains("Microsoft.Storage/storageAccounts") && + array.Contains("Microsoft.Compute/virtualMachines")), + subscriptionId, + null, + null, + null); + } + + [Fact] + public async Task Should_return_null_results_when_no_regions_found() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var resourceTypes = "Microsoft.Web/sites"; + + _quotaService.GetAvailableRegionsForResourceTypesAsync( + Arg.Any(), + subscriptionId, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new List()); + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.Null(result.Results); // Should be null when no regions are found + } + + [Fact] + public async Task Should_include_all_cognitive_service_parameters() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var resourceTypes = "Microsoft.CognitiveServices/accounts"; + var cognitiveServiceModelName = "gpt-4o"; + var cognitiveServiceModelVersion = "2024-05-13"; + var cognitiveServiceDeploymentSkuName = "Standard"; + + var expectedRegions = new List { "eastus" }; + + _quotaService.GetAvailableRegionsForResourceTypesAsync( + Arg.Any(), + subscriptionId, + cognitiveServiceModelName, + cognitiveServiceModelVersion, + cognitiveServiceDeploymentSkuName) + .Returns(expectedRegions); + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--resource-types", resourceTypes, + "--cognitive-service-model-name", cognitiveServiceModelName, + "--cognitive-service-model-version", cognitiveServiceModelVersion, + "--cognitive-service-deployment-sku-name", cognitiveServiceDeploymentSkuName + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + + // Verify the service was called with all cognitive service parameters + await _quotaService.Received(1).GetAvailableRegionsForResourceTypesAsync( + Arg.Is(array => + array.Length == 1 && + array.Contains("Microsoft.CognitiveServices/accounts")), + subscriptionId, + cognitiveServiceModelName, + cognitiveServiceModelVersion, + cognitiveServiceDeploymentSkuName); + } +} diff --git a/tests/Areas/Quota/UnitTests/UsageCheckCommandTests.cs b/tests/Areas/Quota/UnitTests/UsageCheckCommandTests.cs new file mode 100644 index 000000000..ef904eec6 --- /dev/null +++ b/tests/Areas/Quota/UnitTests/UsageCheckCommandTests.cs @@ -0,0 +1,273 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Parsing; +using System.Text.Json; +using AzureMcp.Areas.Quota.Commands; +using AzureMcp.Areas.Quota.Services; +using AzureMcp.Areas.Quota.Services.Util; +using AzureMcp.Models.Command; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace AzureMcp.Tests.Areas.Quota.UnitTests; + +[Trait("Area", "Quota")] +public sealed class UsageCheckCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IQuotaService _quotaService; + private readonly ILogger _logger; + private readonly UsageCheckCommand _command; + private readonly Parser _parser; + + public UsageCheckCommandTests() + { + _quotaService = Substitute.For(); + _logger = Substitute.For>(); + + var services = new ServiceCollection(); + services.AddSingleton(_quotaService); + _serviceProvider = services.BuildServiceProvider(); + + _command = new UsageCheckCommand(_logger); + _parser = new Parser(_command.GetCommand()); + } + + [Fact] + public async Task Should_check_azure_quota_success() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var region = "eastus"; + var resourceTypes = "Microsoft.App, Microsoft.Storage/storageAccounts"; + + var expectedQuotaInfo = new Dictionary> + { + { + "Microsoft.App", + new List + { + new("ContainerApps", 100, 5, "Count"), + new("ContainerAppsEnvironments", 10, 2, "Count") + } + }, + { + "Microsoft.Storage/storageAccounts", + new List + { + new("StorageAccounts", 250, 15, "Count"), + new("TotalStorageSize", 500, 150, "TB") + } + } + }; + + _quotaService.GetAzureQuotaAsync( + Arg.Is>(list => + list.Count == 2 && + list.Contains("Microsoft.App") && + list.Contains("Microsoft.Storage/storageAccounts")), + subscriptionId, + region) + .Returns(expectedQuotaInfo); + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--region", region, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Results); + + // Verify the service was called with the correct parameters + await _quotaService.Received(1).GetAzureQuotaAsync( + Arg.Is>(list => + list.Count == 2 && + list.Contains("Microsoft.App") && + list.Contains("Microsoft.Storage/storageAccounts")), + subscriptionId, + region); + + // Verify the response structure + var json = JsonSerializer.Serialize(result.Results); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + var response = JsonSerializer.Deserialize(json, options); + Assert.NotNull(response); + Assert.NotNull(response.UsageInfo); + Assert.Equal(2, response.UsageInfo.Count); + + // Verify Microsoft.App quotas + Assert.True(response.UsageInfo.ContainsKey("Microsoft.App")); + var appQuotas = response.UsageInfo["Microsoft.App"]; + Assert.Equal(2, appQuotas.Count); + Assert.Contains(appQuotas, q => q.Name == "ContainerApps" && q.Limit == 100 && q.Used == 5); + Assert.Contains(appQuotas, q => q.Name == "ContainerAppsEnvironments" && q.Limit == 10 && q.Used == 2); + + // Verify Microsoft.Storage/storageAccounts quotas + Assert.True(response.UsageInfo.ContainsKey("Microsoft.Storage/storageAccounts")); + var storageQuotas = response.UsageInfo["Microsoft.Storage/storageAccounts"]; + Assert.Equal(2, storageQuotas.Count); + Assert.Contains(storageQuotas, q => q.Name == "StorageAccounts" && q.Limit == 250 && q.Used == 15); + Assert.Contains(storageQuotas, q => q.Name == "TotalStorageSize" && q.Limit == 500 && q.Used == 150); + } + + [Fact] + public async Task Should_ReturnError_empty_resource_types() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var region = "eastus"; + var resourceTypes = ""; + + _quotaService.GetAzureQuotaAsync( + Arg.Any>(), + subscriptionId, + region) + .Returns(new Dictionary>()); + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--region", region, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(400, result.Status); + } + + [Fact] + public async Task Should_handle_service_exception() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var region = "eastus"; + var resourceTypes = "Microsoft.App"; + var expectedException = new Exception("Service error occurred"); + + _quotaService.GetAzureQuotaAsync( + Arg.Any>(), + subscriptionId, + region) + .ThrowsAsync(expectedException); + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--region", region, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(500, result.Status); + Assert.Contains("Service error occurred", result.Message); + } + + [Fact] + public async Task Should_parse_resource_types_with_spaces() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var region = "westus2"; + var resourceTypes = " Microsoft.Web/sites , Microsoft.Storage/storageAccounts , Microsoft.Compute/virtualMachines "; + + var expectedQuotaInfo = new Dictionary> + { + { "Microsoft.Web/sites", new List { new("WebApps", 10, 3, "Count") } }, + { "Microsoft.Storage/storageAccounts", new List { new("StorageAccounts", 250, 15, "Count") } }, + { "Microsoft.Compute/virtualMachines", new List { new("VMs", 50, 10, "Count") } } + }; + + _quotaService.GetAzureQuotaAsync( + Arg.Is>(list => + list.Count == 3 && + list.Contains("Microsoft.Web/sites") && + list.Contains("Microsoft.Storage/storageAccounts") && + list.Contains("Microsoft.Compute/virtualMachines")), + subscriptionId, + region) + .Returns(expectedQuotaInfo); + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--region", region, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + + // Verify the service was called with correctly parsed resource types + await _quotaService.Received(1).GetAzureQuotaAsync( + Arg.Is>(list => + list.Count == 3 && + list.Contains("Microsoft.Web/sites") && + list.Contains("Microsoft.Storage/storageAccounts") && + list.Contains("Microsoft.Compute/virtualMachines")), + subscriptionId, + region); + } + + [Fact] + public async Task Should_return_null_results_when_no_quotas_found() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var region = "eastus"; + var resourceTypes = "Microsoft.App"; + + _quotaService.GetAzureQuotaAsync( + Arg.Any>(), + subscriptionId, + region) + .Returns(new Dictionary>()); + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--region", region, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.Null(result.Results); // Should be null when no quotas are found + } +} From 969c18eabbc5925b93a5516bb97390f8e740a9a5 Mon Sep 17 00:00:00 2001 From: wchigit <129354560+wchigit@users.noreply.github.com> Date: Thu, 24 Jul 2025 10:40:42 +0800 Subject: [PATCH 09/56] [GenerateArchitectureDiagramCommand] Fix and AKS support (#8) * init aks diagram support * fix and test * fix * prompt fix encoding issue * fix by replacing + with - * fix encoding * fix test * resolve conflicts * cspell and format --- .vscode/cspell.json | 7 ++ .../GenerateArchitectureDiagramCommand.cs | 2 +- .../Deploy/Commands/GenerateMermaidChart.cs | 78 ++++++++++++++++++- src/Areas/Deploy/Models/Consts.cs | 4 +- .../Deploy/Options/DeployOptionDefinitions.cs | 6 +- src/Areas/Quota/Commands/QuotaJsonContext.cs | 2 +- src/Areas/Quota/Commands/UsageCheckCommand.cs | 2 +- .../UnitTests/ArchitectureDiagramTests.cs | 51 +++++++++++- .../UnitTests/AzdAppLogGetCommandTests.cs | 32 ++++---- .../Deploy/UnitTests/PlanGetCommandTests.cs | 2 +- .../UnitTests/RegionCheckCommandTests.cs | 54 ++++++------- .../Quota/UnitTests/UsageCheckCommandTests.cs | 36 ++++----- 12 files changed, 202 insertions(+), 74 deletions(-) diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 74829f34c..7cc66d605 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -170,11 +170,14 @@ "1espt", "accesspolicy", "ADMINPROVIDER", + "akscluster", + "aksservice", "alcoop", "Apim", "appconfig", "appservice", "azapi", + "azcli", "azext", "azmcp", "azqr", @@ -218,6 +221,7 @@ "azurevirtualnetwork", "azurewebpubsub", "azurefunctions", + "backendservice", "codegen", "codeium", "cslschema", @@ -251,6 +255,7 @@ "exfiltration", "filefilters", "fnames", + "frontendservice", "gethealth", "grpcio", "healthmodels", @@ -305,6 +310,7 @@ "MACPOOL", "MACVMIMAGE", "Newtonsoft", + "northcentralus", "Npgsql", "norequired", "npmjs", @@ -336,6 +342,7 @@ "skillset", "skillsets", "staticwebapp", + "staticwebapps", "submode", "syslib", "testresource", diff --git a/src/Areas/Deploy/Commands/GenerateArchitectureDiagramCommand.cs b/src/Areas/Deploy/Commands/GenerateArchitectureDiagramCommand.cs index 6856e95b3..7a01dc711 100644 --- a/src/Areas/Deploy/Commands/GenerateArchitectureDiagramCommand.cs +++ b/src/Areas/Deploy/Commands/GenerateArchitectureDiagramCommand.cs @@ -79,7 +79,7 @@ public override Task ExecuteAsync(CommandContext context, Parse { throw new InvalidOperationException("Failed to generate architecture diagram. The chart content is empty."); } - var encodedDiagram = EncodeMermaid.GetEncodedMermaidChart(chart); + var encodedDiagram = EncodeMermaid.GetEncodedMermaidChart(chart).Replace("+", "-").Replace("/", "_"); // replace '+' with '-' and "/" with "_" for URL safety and consistency with mermaid.live URL encoding var mermaidUrl = $"https://mermaid.live/view#pako:{encodedDiagram}"; _logger.LogInformation("Generated architecture diagram successfully. Mermaid URL: {MermaidUrl}", mermaidUrl); diff --git a/src/Areas/Deploy/Commands/GenerateMermaidChart.cs b/src/Areas/Deploy/Commands/GenerateMermaidChart.cs index c5c4cbe2c..b76f6c8e6 100644 --- a/src/Areas/Deploy/Commands/GenerateMermaidChart.cs +++ b/src/Areas/Deploy/Commands/GenerateMermaidChart.cs @@ -1,13 +1,19 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Immutable; using System.Text; using AzureMcp.Areas.Deploy.Options; +using Microsoft.Extensions.ObjectPool; namespace AzureMcp.Areas.Deploy.Commands; public static class GenerateMermaidChart { + // used to create a subgraph for AKS cluster in the chart + private const string aksClusterInternalName = "akscluster"; + private const string aksClusterName = "Azure Kubernetes Service (AKS) Cluster"; + public static string GenerateChart(string workspaceFolder, AppTopology appTopology) { var chartComponents = new List(); @@ -20,6 +26,10 @@ public static string GenerateChart(string workspaceFolder, AppTopology appTopolo classDef compute fill:#9cf00b,stroke:#333,stroke-width:2px,color:#000 classDef binding fill:#fef200,stroke:#333,stroke-width:2px,color:#000 """); + if (appTopology.Services.Any(s => s.AzureComputeHost == "aks")) + { + chartComponents.Add("classDef cluster fill:#ffffd0,stroke:#333,stroke-width:2px,color:#000"); + } var services = new List { "%% Services" }; var resources = new List { "%% Resources" }; @@ -45,26 +55,52 @@ public static string GenerateChart(string workspaceFolder, AppTopology appTopolo services.Add(CreateComponentName(serviceInternalName, string.Join("\n", serviceName), "service", NodeShape.Rectangle)); - relationships.Add(CreateRelationshipString(serviceInternalName, service.Name, "hosted on", ArrowType.Solid)); + relationships.Add(CreateRelationshipString(serviceInternalName, $"{FlattenServiceType(service.AzureComputeHost)}_{service.Name}", "hosted on", ArrowType.Solid)); } + var aksClusterExists = false; foreach (var service in appTopology.Services) { + string serviceResourceInternalName = $"{FlattenServiceType(service.AzureComputeHost)}_{service.Name}"; + if (service.AzureComputeHost == "aks") + { + if (!aksClusterExists) + { + // Add AKS cluster as a subgraph + resources.Add($"subgraph {aksClusterInternalName} [\"{aksClusterName}\"]"); + // containerized services share the same AKS cluster + foreach (var aksservice in appTopology.Services.Where(s => s.AzureComputeHost == "aks")) + { + resources.Add(CreateComponentName($"{aksservice.AzureComputeHost}_{aksservice.Name}", $"{aksservice.Name} (Containerized Service)", "compute", NodeShape.RoundedRectangle)); + } + resources.Add("end"); + resources.Add($"{aksClusterInternalName}:::cluster"); + aksClusterExists = true; + } + } + // each service should have a compute resource type + else if (!resources.Any(r => r.Contains(serviceResourceInternalName))) + { + resources.Add(CreateComponentName(serviceResourceInternalName, $"{service.Name} ({GetFormalName(service.AzureComputeHost)})", "compute", NodeShape.RoundedRectangle)); + } foreach (var dependency in service.Dependencies) { var instanceInternalName = $"{FlattenServiceType(dependency.ServiceType)}.{dependency.Name}"; - var instanceName = $"{dependency.Name} ({dependency.ServiceType})"; + var instanceName = $"{dependency.Name} ({GetFormalName(dependency.ServiceType)})"; if (IsComputeResourceType(dependency.ServiceType)) { - resources.Add(CreateComponentName(instanceInternalName, instanceName, "compute", NodeShape.RoundedRectangle)); + if (!resources.Any(r => r.Contains(EnsureUrlFriendlyName(instanceInternalName)))) + { + resources.Add(CreateComponentName(instanceInternalName, instanceName, "compute", NodeShape.RoundedRectangle)); + } } else { resources.Add(CreateComponentName(instanceInternalName, instanceName, "binding", NodeShape.Circle)); } - relationships.Add(CreateRelationshipString(service.Name, instanceInternalName, dependency.ConnectionType, ArrowType.Dotted)); + relationships.Add(CreateRelationshipString(serviceResourceInternalName, instanceInternalName, dependency.ConnectionType, ArrowType.Dotted)); } } @@ -119,6 +155,16 @@ private static string GetArrowSymbol(ArrowType arrowType) }; } + private static string GetFormalName(string name) + { + if (ResourceTypeConverter.TryGetValue(name, out var formalName)) + { + return formalName; + } + return name; + + } + private static string FlattenServiceType(string serviceType) { return serviceType.ToLowerInvariant().Replace("azure", ""); @@ -128,6 +174,30 @@ private static bool IsComputeResourceType(string serviceType) { return Enum.GetNames().Contains(serviceType, StringComparer.OrdinalIgnoreCase); } + + private static IDictionary ResourceTypeConverter = new Dictionary + { + { "appservice", "Azure App Service" }, + { "containerapp", "Azure Container Apps" }, + { "functionapp", "Azure Functions" }, + { "staticwebapp", "Azure Static Web Apps" }, + { "aks", "Azure Kubernetes Services" }, + { "azureaisearch", "Azure AI Search" }, + { "azureaiservices", "Azure AI Services" }, + { "azureapplicationinsights", "Azure Application Insights" }, + { "azurebotservice", "Azure Bot Service" }, + { "azurecosmosdb", "Azure Cosmos DB" }, + { "azurekeyvault", "Azure Key Vault" }, + { "azuredatabaseformysql", "Azure Database for MySQL" }, + { "azureopenai", "Azure OpenAI" }, + { "azuredatabaseforpostgresql", "Azure Database for PostgreSQL" }, + { "azuresqldatabase", "Azure SQL Database" }, + { "azurecacheforredis", "Azure Cache For Redis"}, + { "azurestorageaccount", "Azure Storage Account" }, + { "azureservicebus", "Azure Service Bus" }, + { "azurewebpubsub", "Azure Web PubSub"} + }; + } public enum NodeShape diff --git a/src/Areas/Deploy/Models/Consts.cs b/src/Areas/Deploy/Models/Consts.cs index e3b68f5b6..cd2c8857a 100644 --- a/src/Areas/Deploy/Models/Consts.cs +++ b/src/Areas/Deploy/Models/Consts.cs @@ -10,7 +10,8 @@ public enum AzureComputeServiceType AppService, FunctionApp, ContainerApp, - StaticWebApp + StaticWebApp, + AKS } public enum AzureServiceType @@ -24,6 +25,7 @@ public enum AzureServiceType AzureCosmosDB, AzureFunctionApp, AzureKeyVault, + AKS, AzureDatabaseForMySQL, AzureOpenAI, AzureDatabaseForPostgreSQL, diff --git a/src/Areas/Deploy/Options/DeployOptionDefinitions.cs b/src/Areas/Deploy/Options/DeployOptionDefinitions.cs index 713bf2b6e..787bb4d0e 100644 --- a/src/Areas/Deploy/Options/DeployOptionDefinitions.cs +++ b/src/Areas/Deploy/Options/DeployOptionDefinitions.cs @@ -222,7 +222,7 @@ public static class AppTopologySchema { ["type"] = "string", ["description"] = "The appropriate azure service that should be used to host this service. Use containerapp if the service is containerized and has a Dockerfile.", - ["enum"] = new JsonArray("appservice", "containerapp", "function", "staticwebapp") + ["enum"] = new JsonArray("appservice", "containerapp", "function", "staticwebapp", "aks") }, ["dockerSettings"] = new JsonObject { @@ -255,13 +255,13 @@ public static class AppTopologySchema ["name"] = new JsonObject { ["type"] = "string", - ["description"] = "The name of the dependent service. Can be arbitrary, or must reference another service in the services array if referencing azureappservice, azurecontainerapp, azurestaticwebapps, or azurefunctions." + ["description"] = "The name of the dependent service. Can be arbitrary, or must reference another service in the services array if referencing appservice, containerapp, staticwebapps, aks, or functionapp." }, ["serviceType"] = new JsonObject { ["type"] = "string", ["description"] = "The name of the azure service that can be used for this dependent service.", - ["enum"] = new JsonArray("azureaisearch", "azureaiservices", "appservice", "azureapplicationinsights", "azurebotservice", "containerapp", "azurecosmosdb", "functionapp", "azurekeyvault", "azuredatabaseformysql", "azureopenai", "azuredatabaseforpostgresql", "azureprivateendpoint", "azurecacheforredis", "azuresqldatabase", "azurestorageaccount", "staticwebapp", "azureservicebus", "azuresignalrservice", "azurevirtualnetwork", "azurewebpubsub") + ["enum"] = new JsonArray("azureaisearch", "azureaiservices", "appservice", "azureapplicationinsights", "azurebotservice", "containerapp", "azurecosmosdb", "functionapp", "azurekeyvault", "aks", "azuredatabaseformysql", "azureopenai", "azuredatabaseforpostgresql", "azureprivateendpoint", "azurecacheforredis", "azuresqldatabase", "azurestorageaccount", "staticwebapp", "azureservicebus", "azuresignalrservice", "azurevirtualnetwork", "azurewebpubsub") }, ["connectionType"] = new JsonObject { diff --git a/src/Areas/Quota/Commands/QuotaJsonContext.cs b/src/Areas/Quota/Commands/QuotaJsonContext.cs index 31ce9ee5a..3c5396683 100644 --- a/src/Areas/Quota/Commands/QuotaJsonContext.cs +++ b/src/Areas/Quota/Commands/QuotaJsonContext.cs @@ -2,8 +2,8 @@ // Licensed under the MIT License. using System.Text.Json.Serialization; -using AzureMcp.Areas.Quota.Services.Util; using AzureMcp.Areas.Quota.Commands; +using AzureMcp.Areas.Quota.Services.Util; namespace AzureMcp.Areas.Quota.Commands; diff --git a/src/Areas/Quota/Commands/UsageCheckCommand.cs b/src/Areas/Quota/Commands/UsageCheckCommand.cs index 493daf734..728223db3 100644 --- a/src/Areas/Quota/Commands/UsageCheckCommand.cs +++ b/src/Areas/Quota/Commands/UsageCheckCommand.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using AzureMcp.Areas.Quota.Services.Util; using AzureMcp.Areas.Quota.Options; using AzureMcp.Areas.Quota.Services; +using AzureMcp.Areas.Quota.Services.Util; using AzureMcp.Commands; using AzureMcp.Commands.Subscription; using AzureMcp.Models.Command; diff --git a/tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs b/tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs index e19c3d5bc..691104fea 100644 --- a/tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs +++ b/tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs @@ -62,6 +62,55 @@ public async Task GenerateArchitectureDiagram_ShouldReturnEncryptedDiagramUrl() { new DependencyConfig { Name = "store", ConnectionType = "system-identity", ServiceType = "azurestorageaccount" } }, + }, + new ServiceConfig + { + Name = "frontend", + Path = "testWorkspace/web", + AzureComputeHost = "containerapp", + Language = "js", + Port = "8080", + Dependencies = new DependencyConfig[] + { + new DependencyConfig { Name = "backend", ConnectionType = "http", ServiceType = "containerapp" } + } + }, + new ServiceConfig + { + Name = "backend", + Path = "testWorkspace/api", + AzureComputeHost = "containerapp", + Language = "python", + Port = "3000", + Dependencies = new DependencyConfig[] + { + new DependencyConfig { Name = "db", ConnectionType = "secret", ServiceType = "azurecosmosdb" }, + new DependencyConfig { Name = "secretStore", ConnectionType = "system-identity", ServiceType = "azurekeyvault" } + } + }, + new ServiceConfig + { + Name = "frontendservice", + Path = "testWorkspace/web", + AzureComputeHost = "aks", + Language = "ts", + Port = "3001", + Dependencies = new DependencyConfig[] + { + new DependencyConfig { Name = "backendservice", ConnectionType = "user-identity", ServiceType = "aks"} + } + }, + new ServiceConfig + { + Name = "backendservice", + Path = "testWorkspace/api", + AzureComputeHost = "aks", + Language = "python", + Port = "3000", + Dependencies = new DependencyConfig[] + { + new DependencyConfig { Name = "database", ConnectionType = "user-identity", ServiceType = "azurecacheforredis" } + } } } }; @@ -84,7 +133,7 @@ public async Task GenerateArchitectureDiagram_ShouldReturnEncryptedDiagramUrl() var extractedUrl = response.Message.Substring(urlStartPosition, urlEndPosition - urlStartPosition); Assert.StartsWith(urlPattern, extractedUrl); - var encodedDiagram = extractedUrl.Substring(urlPattern.Length); + var encodedDiagram = extractedUrl.Substring(urlPattern.Length).Replace("_", "/").Replace("-", "+"); // Replace back for decoding var decodedDiagram = EncodeMermaid.GetDecodedMermaidChart(encodedDiagram); Assert.NotEmpty(decodedDiagram); Assert.Contains("website", decodedDiagram); diff --git a/tests/Areas/Deploy/UnitTests/AzdAppLogGetCommandTests.cs b/tests/Areas/Deploy/UnitTests/AzdAppLogGetCommandTests.cs index 7b935ac37..fbc02d335 100644 --- a/tests/Areas/Deploy/UnitTests/AzdAppLogGetCommandTests.cs +++ b/tests/Areas/Deploy/UnitTests/AzdAppLogGetCommandTests.cs @@ -42,9 +42,9 @@ public async Task Should_get_azd_app_logs() // arrange var expectedLogs = "App logs retrieved:\n[2024-01-01 10:00:00] Application started\n[2024-01-01 10:01:00] Processing request"; _deployService.GetAzdResourceLogsAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any()) .Returns(expectedLogs); @@ -73,9 +73,9 @@ public async Task Should_get_azd_app_logs_with_default_limit() // arrange var expectedLogs = "App logs retrieved:\nSample log entry"; _deployService.GetAzdResourceLogsAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any()) .Returns(expectedLogs); @@ -101,9 +101,9 @@ public async Task Should_handle_no_logs_found() { // arrange _deployService.GetAzdResourceLogsAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any()) .Returns("No logs found."); @@ -129,9 +129,9 @@ public async Task Should_handle_error_during_log_retrieval() // arrange var errorMessage = "Error during retrieval of app logs of azd project:\nNo resource group with tag {\"azd-env-name\": test-env} found."; _deployService.GetAzdResourceLogsAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any()) .Returns(errorMessage); @@ -157,9 +157,9 @@ public async Task Should_handle_service_exception() { // arrange _deployService.GetAzdResourceLogsAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("Failed to connect to Azure")); @@ -197,5 +197,5 @@ public async Task Should_validate_required_parameters() Assert.NotEqual(200, result.Status); // Should fail validation } - + } diff --git a/tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs b/tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs index f8a9c43a9..fd8e8db02 100644 --- a/tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs +++ b/tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs @@ -18,7 +18,7 @@ public class PlanGetCommandTests private readonly ILogger _logger; private readonly Parser _parser; private readonly CommandContext _context; - private readonly PlanGetCommand _command; + private readonly PlanGetCommand _command; public PlanGetCommandTests() { diff --git a/tests/Areas/Quota/UnitTests/RegionCheckCommandTests.cs b/tests/Areas/Quota/UnitTests/RegionCheckCommandTests.cs index 4fd9678e0..f979da55a 100644 --- a/tests/Areas/Quota/UnitTests/RegionCheckCommandTests.cs +++ b/tests/Areas/Quota/UnitTests/RegionCheckCommandTests.cs @@ -27,11 +27,11 @@ public RegionCheckCommandTests() { _quotaService = Substitute.For(); _logger = Substitute.For>(); - + var services = new ServiceCollection(); services.AddSingleton(_quotaService); _serviceProvider = services.BuildServiceProvider(); - + _command = new RegionCheckCommand(_logger); _parser = new Parser(_command.GetCommand()); } @@ -42,7 +42,7 @@ public async Task Should_check_azure_regions_success() // Arrange var subscriptionId = "test-subscription-id"; var resourceTypes = "Microsoft.Web/sites, Microsoft.Storage/storageAccounts"; - + var expectedRegions = new List { "eastus", @@ -53,9 +53,9 @@ public async Task Should_check_azure_regions_success() }; _quotaService.GetAvailableRegionsForResourceTypesAsync( - Arg.Is(array => - array.Length == 2 && - array.Contains("Microsoft.Web/sites") && + Arg.Is(array => + array.Length == 2 && + array.Contains("Microsoft.Web/sites") && array.Contains("Microsoft.Storage/storageAccounts")), subscriptionId, Arg.Any(), @@ -80,9 +80,9 @@ public async Task Should_check_azure_regions_success() // Verify the service was called with the correct parameters await _quotaService.Received(1).GetAvailableRegionsForResourceTypesAsync( - Arg.Is(array => - array.Length == 2 && - array.Contains("Microsoft.Web/sites") && + Arg.Is(array => + array.Length == 2 && + array.Contains("Microsoft.Web/sites") && array.Contains("Microsoft.Storage/storageAccounts")), subscriptionId, null, @@ -101,7 +101,7 @@ await _quotaService.Received(1).GetAvailableRegionsForResourceTypesAsync( Assert.NotNull(response); Assert.NotNull(response.AvailableRegions); Assert.Equal(5, response.AvailableRegions.Count); - + // Verify the expected regions are returned Assert.Contains("eastus", response.AvailableRegions); Assert.Contains("westus", response.AvailableRegions); @@ -118,7 +118,7 @@ public async Task Should_check_regions_with_cognitive_services_success() var resourceTypes = "Microsoft.CognitiveServices/accounts"; var cognitiveServiceModelName = "gpt-4o"; var cognitiveServiceDeploymentSkuName = "Standard"; - + var expectedRegions = new List { "eastus", @@ -127,8 +127,8 @@ public async Task Should_check_regions_with_cognitive_services_success() }; _quotaService.GetAvailableRegionsForResourceTypesAsync( - Arg.Is(array => - array.Length == 1 && + Arg.Is(array => + array.Length == 1 && array.Contains("Microsoft.CognitiveServices/accounts")), subscriptionId, cognitiveServiceModelName, @@ -155,8 +155,8 @@ public async Task Should_check_regions_with_cognitive_services_success() // Verify the service was called with the correct parameters await _quotaService.Received(1).GetAvailableRegionsForResourceTypesAsync( - Arg.Is(array => - array.Length == 1 && + Arg.Is(array => + array.Length == 1 && array.Contains("Microsoft.CognitiveServices/accounts")), subscriptionId, cognitiveServiceModelName, @@ -175,7 +175,7 @@ await _quotaService.Received(1).GetAvailableRegionsForResourceTypesAsync( Assert.NotNull(response); Assert.NotNull(response.AvailableRegions); Assert.Equal(3, response.AvailableRegions.Count); - + // Verify the expected regions are returned Assert.Contains("eastus", response.AvailableRegions); Assert.Contains("westus2", response.AvailableRegions); @@ -203,7 +203,7 @@ public async Task Should_ReturnError_empty_resource_types() Assert.NotNull(result); Assert.Equal(400, result.Status); Assert.Contains("Missing Required options: --resource-types", result.Message); - + // Verify the service was not called await _quotaService.DidNotReceive().GetAvailableRegionsForResourceTypesAsync( Arg.Any(), @@ -251,13 +251,13 @@ public async Task Should_parse_multiple_resource_types_with_spaces() // Arrange var subscriptionId = "test-subscription-id"; var resourceTypes = " Microsoft.Web/sites , Microsoft.Storage/storageAccounts , Microsoft.Compute/virtualMachines "; - + var expectedRegions = new List { "eastus", "westus2" }; _quotaService.GetAvailableRegionsForResourceTypesAsync( - Arg.Is(array => - array.Length == 3 && - array.Contains("Microsoft.Web/sites") && + Arg.Is(array => + array.Length == 3 && + array.Contains("Microsoft.Web/sites") && array.Contains("Microsoft.Storage/storageAccounts") && array.Contains("Microsoft.Compute/virtualMachines")), subscriptionId, @@ -282,9 +282,9 @@ public async Task Should_parse_multiple_resource_types_with_spaces() // Verify the service was called with correctly parsed resource types await _quotaService.Received(1).GetAvailableRegionsForResourceTypesAsync( - Arg.Is(array => - array.Length == 3 && - array.Contains("Microsoft.Web/sites") && + Arg.Is(array => + array.Length == 3 && + array.Contains("Microsoft.Web/sites") && array.Contains("Microsoft.Storage/storageAccounts") && array.Contains("Microsoft.Compute/virtualMachines")), subscriptionId, @@ -333,7 +333,7 @@ public async Task Should_include_all_cognitive_service_parameters() var cognitiveServiceModelName = "gpt-4o"; var cognitiveServiceModelVersion = "2024-05-13"; var cognitiveServiceDeploymentSkuName = "Standard"; - + var expectedRegions = new List { "eastus" }; _quotaService.GetAvailableRegionsForResourceTypesAsync( @@ -363,8 +363,8 @@ public async Task Should_include_all_cognitive_service_parameters() // Verify the service was called with all cognitive service parameters await _quotaService.Received(1).GetAvailableRegionsForResourceTypesAsync( - Arg.Is(array => - array.Length == 1 && + Arg.Is(array => + array.Length == 1 && array.Contains("Microsoft.CognitiveServices/accounts")), subscriptionId, cognitiveServiceModelName, diff --git a/tests/Areas/Quota/UnitTests/UsageCheckCommandTests.cs b/tests/Areas/Quota/UnitTests/UsageCheckCommandTests.cs index ef904eec6..dc8cd8d5f 100644 --- a/tests/Areas/Quota/UnitTests/UsageCheckCommandTests.cs +++ b/tests/Areas/Quota/UnitTests/UsageCheckCommandTests.cs @@ -28,11 +28,11 @@ public UsageCheckCommandTests() { _quotaService = Substitute.For(); _logger = Substitute.For>(); - + var services = new ServiceCollection(); services.AddSingleton(_quotaService); _serviceProvider = services.BuildServiceProvider(); - + _command = new UsageCheckCommand(_logger); _parser = new Parser(_command.GetCommand()); } @@ -44,7 +44,7 @@ public async Task Should_check_azure_quota_success() var subscriptionId = "test-subscription-id"; var region = "eastus"; var resourceTypes = "Microsoft.App, Microsoft.Storage/storageAccounts"; - + var expectedQuotaInfo = new Dictionary> { { @@ -66,9 +66,9 @@ public async Task Should_check_azure_quota_success() }; _quotaService.GetAzureQuotaAsync( - Arg.Is>(list => - list.Count == 2 && - list.Contains("Microsoft.App") && + Arg.Is>(list => + list.Count == 2 && + list.Contains("Microsoft.App") && list.Contains("Microsoft.Storage/storageAccounts")), subscriptionId, region) @@ -92,9 +92,9 @@ public async Task Should_check_azure_quota_success() // Verify the service was called with the correct parameters await _quotaService.Received(1).GetAzureQuotaAsync( - Arg.Is>(list => - list.Count == 2 && - list.Contains("Microsoft.App") && + Arg.Is>(list => + list.Count == 2 && + list.Contains("Microsoft.App") && list.Contains("Microsoft.Storage/storageAccounts")), subscriptionId, region); @@ -111,14 +111,14 @@ await _quotaService.Received(1).GetAzureQuotaAsync( Assert.NotNull(response); Assert.NotNull(response.UsageInfo); Assert.Equal(2, response.UsageInfo.Count); - + // Verify Microsoft.App quotas Assert.True(response.UsageInfo.ContainsKey("Microsoft.App")); var appQuotas = response.UsageInfo["Microsoft.App"]; Assert.Equal(2, appQuotas.Count); Assert.Contains(appQuotas, q => q.Name == "ContainerApps" && q.Limit == 100 && q.Used == 5); Assert.Contains(appQuotas, q => q.Name == "ContainerAppsEnvironments" && q.Limit == 10 && q.Used == 2); - + // Verify Microsoft.Storage/storageAccounts quotas Assert.True(response.UsageInfo.ContainsKey("Microsoft.Storage/storageAccounts")); var storageQuotas = response.UsageInfo["Microsoft.Storage/storageAccounts"]; @@ -196,7 +196,7 @@ public async Task Should_parse_resource_types_with_spaces() var subscriptionId = "test-subscription-id"; var region = "westus2"; var resourceTypes = " Microsoft.Web/sites , Microsoft.Storage/storageAccounts , Microsoft.Compute/virtualMachines "; - + var expectedQuotaInfo = new Dictionary> { { "Microsoft.Web/sites", new List { new("WebApps", 10, 3, "Count") } }, @@ -205,9 +205,9 @@ public async Task Should_parse_resource_types_with_spaces() }; _quotaService.GetAzureQuotaAsync( - Arg.Is>(list => - list.Count == 3 && - list.Contains("Microsoft.Web/sites") && + Arg.Is>(list => + list.Count == 3 && + list.Contains("Microsoft.Web/sites") && list.Contains("Microsoft.Storage/storageAccounts") && list.Contains("Microsoft.Compute/virtualMachines")), subscriptionId, @@ -231,9 +231,9 @@ public async Task Should_parse_resource_types_with_spaces() // Verify the service was called with correctly parsed resource types await _quotaService.Received(1).GetAzureQuotaAsync( - Arg.Is>(list => - list.Count == 3 && - list.Contains("Microsoft.Web/sites") && + Arg.Is>(list => + list.Count == 3 && + list.Contains("Microsoft.Web/sites") && list.Contains("Microsoft.Storage/storageAccounts") && list.Contains("Microsoft.Compute/virtualMachines")), subscriptionId, From 23aa942faeac34bc3de8b4f99bf9f1c5ec5bffc0 Mon Sep 17 00:00:00 2001 From: qianwens Date: Thu, 24 Jul 2025 13:05:43 +0800 Subject: [PATCH 10/56] fix the iac-rules-get tool name in e2etestprompt.md --- e2eTests/e2eTestPrompts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2eTests/e2eTestPrompts.md b/e2eTests/e2eTestPrompts.md index b8c441a81..e264cb147 100644 --- a/e2eTests/e2eTestPrompts.md +++ b/e2eTests/e2eTestPrompts.md @@ -342,7 +342,7 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | Tool Name | Test Prompt | |:----------|:----------| | azmcp-deploy-plan-get | Create a plan to deploy this application to azure | -| azmcp-deploy-infra-code-rules-get | Show me the rules to generate bicep scripts | +| azmcp-deploy-iac-rules-get | Show me the rules to generate bicep scripts | | azmcp-deploy-azd-app-log-get | Show me the log of the application deployed by azd | | azmcp-deploy-cicd-pipeline-guidance-get | How can I create a CI/CD pipeline to deploy this app to Azure? | | azmcp-deploy-architecture-diagram-generate | Generate the azure architecture diagram for this application | From a144fe8afaf13ea60f93ed2f66b3f0849157c9c2 Mon Sep 17 00:00:00 2001 From: xfz11 <81600993+xfz11@users.noreply.github.com> Date: Mon, 28 Jul 2025 03:49:59 +0000 Subject: [PATCH 11/56] Refactor: extract prompt to md file (#10) * fix * extract prompt to md file * rename available-region-list --- docs/azmcp-commands.md | 2 +- e2eTests/e2eTestPrompts.md | 2 +- .../Deploy/Commands/IaCRulesGetCommand.cs | 4 +- src/Areas/Deploy/Commands/PlanGetCommand.cs | 2 +- .../DeploymentPlanTemplateParameters.cs | 63 ++++++ .../Templates/IaCRulesTemplateParameters.cs | 55 +++++ .../Services/Templates/TemplateService.cs | 74 ++++++ .../Util/DeploymentPlanTemplateUtilV2.cs | 173 ++++++++++++++ .../Services/Util/IaCRulesTemplateUtil.cs | 211 ++++++++++++++++++ .../Templates/IaCRules/appservice-rules.md | 2 + .../Deploy/Templates/IaCRules/azcli-rules.md | 1 + .../Deploy/Templates/IaCRules/azd-rules.md | 5 + .../Templates/IaCRules/base-iac-rules.md | 16 ++ .../Deploy/Templates/IaCRules/bicep-rules.md | 3 + .../Templates/IaCRules/containerapp-rules.md | 9 + .../Templates/IaCRules/final-instructions.md | 1 + .../Templates/IaCRules/functionapp-rules.md | 10 + .../Templates/IaCRules/terraform-rules.md | 2 + src/Areas/Deploy/Templates/Plan/aks-steps.md | 6 + .../Deploy/Templates/Plan/azcli-steps.md | 4 + src/Areas/Deploy/Templates/Plan/azd-steps.md | 7 + .../Templates/Plan/containerapp-steps.md | 5 + .../Templates/Plan/deployment-plan-base.md | 60 +++++ .../Deploy/Templates/Plan/summary-steps.md | 2 + .../Quota/Commands/RegionCheckCommand.cs | 2 +- src/Areas/Quota/QuotaSetup.cs | 2 +- .../Quota/Services/Util/AzureUsageChecker.cs | 6 +- .../Usage/CognitiveServicesUsageChecker.cs | 2 +- .../Util/Usage/ComputeUsageChecker.cs | 2 +- .../Util/Usage/ContainerAppUsageChecker.cs | 2 +- .../Usage/ContainerInstanceUsageChecker.cs | 2 +- .../Util/Usage/HDInsightUsageChecker.cs | 2 +- .../Util/Usage/MachineLearningUsageChecker.cs | 2 +- .../Util/Usage/NetworkUsageChecker.cs | 2 +- .../Util/Usage/PostgreSQLUsageChecker.cs | 2 +- .../Services/Util/Usage/SearchUsageChecker.cs | 2 +- .../Util/Usage/StorageUsageChecker.cs | 2 +- src/AzureMcp.csproj | 1 + .../Deploy/LiveTests/DeployCommandTests.cs | 4 +- .../DeploymentPlanTemplateUtilV2Tests.cs | 151 +++++++++++++ .../Deploy/UnitTests/PlanGetCommandTests.cs | 8 +- .../Deploy/UnitTests/TemplateServiceTests.cs | 79 +++++++ .../Quota/LiveTests/QuotaCommandTests.cs | 4 +- 43 files changed, 968 insertions(+), 28 deletions(-) create mode 100644 src/Areas/Deploy/Models/Templates/DeploymentPlanTemplateParameters.cs create mode 100644 src/Areas/Deploy/Models/Templates/IaCRulesTemplateParameters.cs create mode 100644 src/Areas/Deploy/Services/Templates/TemplateService.cs create mode 100644 src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtilV2.cs create mode 100644 src/Areas/Deploy/Services/Util/IaCRulesTemplateUtil.cs create mode 100644 src/Areas/Deploy/Templates/IaCRules/appservice-rules.md create mode 100644 src/Areas/Deploy/Templates/IaCRules/azcli-rules.md create mode 100644 src/Areas/Deploy/Templates/IaCRules/azd-rules.md create mode 100644 src/Areas/Deploy/Templates/IaCRules/base-iac-rules.md create mode 100644 src/Areas/Deploy/Templates/IaCRules/bicep-rules.md create mode 100644 src/Areas/Deploy/Templates/IaCRules/containerapp-rules.md create mode 100644 src/Areas/Deploy/Templates/IaCRules/final-instructions.md create mode 100644 src/Areas/Deploy/Templates/IaCRules/functionapp-rules.md create mode 100644 src/Areas/Deploy/Templates/IaCRules/terraform-rules.md create mode 100644 src/Areas/Deploy/Templates/Plan/aks-steps.md create mode 100644 src/Areas/Deploy/Templates/Plan/azcli-steps.md create mode 100644 src/Areas/Deploy/Templates/Plan/azd-steps.md create mode 100644 src/Areas/Deploy/Templates/Plan/containerapp-steps.md create mode 100644 src/Areas/Deploy/Templates/Plan/deployment-plan-base.md create mode 100644 src/Areas/Deploy/Templates/Plan/summary-steps.md create mode 100644 tests/Areas/Deploy/UnitTests/DeploymentPlanTemplateUtilV2Tests.cs create mode 100644 tests/Areas/Deploy/UnitTests/TemplateServiceTests.cs diff --git a/docs/azmcp-commands.md b/docs/azmcp-commands.md index ec921a5a8..7f9dafa1d 100644 --- a/docs/azmcp-commands.md +++ b/docs/azmcp-commands.md @@ -806,7 +806,7 @@ azmcp quota usage-get --subscription \ --resource-types # Get the available regions for the resources types -azmcp quota available-region-get --subscription \ +azmcp quota available-region-list --subscription \ --resource-types \ [--cognitive-service-model-name ] \ [--cognitive-service-model-version ] \ diff --git a/e2eTests/e2eTestPrompts.md b/e2eTests/e2eTestPrompts.md index e264cb147..5e77bf942 100644 --- a/e2eTests/e2eTestPrompts.md +++ b/e2eTests/e2eTestPrompts.md @@ -348,5 +348,5 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | azmcp-deploy-architecture-diagram-generate | Generate the azure architecture diagram for this application | ## Quota -| azmcp-quota-available-region-get | Show me the available regions for these resource types | +| azmcp-quota-available-region-list | Show me the available regions for these resource types | | azmcp-quota-usage-get | Check usage information for in region | \ No newline at end of file diff --git a/src/Areas/Deploy/Commands/IaCRulesGetCommand.cs b/src/Areas/Deploy/Commands/IaCRulesGetCommand.cs index ee1359ee4..5a27f8239 100644 --- a/src/Areas/Deploy/Commands/IaCRulesGetCommand.cs +++ b/src/Areas/Deploy/Commands/IaCRulesGetCommand.cs @@ -66,12 +66,12 @@ public override Task ExecuteAsync(CommandContext context, Parse .Where(rt => !string.IsNullOrWhiteSpace(rt)) .ToArray(); - List result = InfraCodeRuleRetriever.GetIaCRules( + string iacRules = IaCRulesTemplateUtil.GetIaCRules( options.DeploymentTool, options.IacType, resourceTypes); - context.Response.Message = string.Join(Environment.NewLine, result); + context.Response.Message = iacRules; } catch (Exception ex) { diff --git a/src/Areas/Deploy/Commands/PlanGetCommand.cs b/src/Areas/Deploy/Commands/PlanGetCommand.cs index 00c09261c..38552ba9f 100644 --- a/src/Areas/Deploy/Commands/PlanGetCommand.cs +++ b/src/Areas/Deploy/Commands/PlanGetCommand.cs @@ -68,7 +68,7 @@ public override Task ExecuteAsync(CommandContext context, Parse return Task.FromResult(context.Response); } - var planTemplate = DeploymentPlanTemplateUtil.GetPlanTemplate(options.ProjectName, options.TargetAppService, options.ProvisioningTool, options.AzdIacOptions); + var planTemplate = DeploymentPlanTemplateUtilV2.GetPlanTemplate(options.ProjectName, options.TargetAppService, options.ProvisioningTool, options.AzdIacOptions); context.Response.Message = planTemplate; context.Response.Status = 200; diff --git a/src/Areas/Deploy/Models/Templates/DeploymentPlanTemplateParameters.cs b/src/Areas/Deploy/Models/Templates/DeploymentPlanTemplateParameters.cs new file mode 100644 index 000000000..9588095bd --- /dev/null +++ b/src/Areas/Deploy/Models/Templates/DeploymentPlanTemplateParameters.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AzureMcp.Areas.Deploy.Models.Templates; + +/// +/// Parameters for generating deployment plan templates. +/// +public sealed class DeploymentPlanTemplateParameters +{ + /// + /// The title of the deployment plan. + /// + public string Title { get; set; } = string.Empty; + + /// + /// The name of the project being deployed. + /// + public string ProjectName { get; set; } = string.Empty; + + /// + /// The target Azure service (ContainerApp, WebApp, FunctionApp, AKS). + /// + public string TargetAppService { get; set; } = string.Empty; + + /// + /// The provisioning tool (AZD, AzCli). + /// + public string ProvisioningTool { get; set; } = string.Empty; + + /// + /// The Infrastructure as Code type (bicep, terraform). + /// + public string IacType { get; set; } = string.Empty; + + /// + /// The Azure compute host display name. + /// + public string AzureComputeHost { get; set; } = string.Empty; + + /// + /// The execution steps for the deployment. + /// + public string ExecutionSteps { get; set; } = string.Empty; + + /// + /// Converts the parameters to a dictionary for template processing. + /// + /// A dictionary with parameter names as keys and their values. + public Dictionary ToDictionary() + { + return new Dictionary + { + { nameof(Title), Title }, + { nameof(ProjectName), ProjectName }, + { nameof(TargetAppService), TargetAppService }, + { nameof(ProvisioningTool), ProvisioningTool }, + { nameof(IacType), IacType }, + { nameof(AzureComputeHost), AzureComputeHost }, + { nameof(ExecutionSteps), ExecutionSteps }, + }; + } +} diff --git a/src/Areas/Deploy/Models/Templates/IaCRulesTemplateParameters.cs b/src/Areas/Deploy/Models/Templates/IaCRulesTemplateParameters.cs new file mode 100644 index 000000000..58e19945c --- /dev/null +++ b/src/Areas/Deploy/Models/Templates/IaCRulesTemplateParameters.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AzureMcp.Areas.Deploy.Models.Templates; + +/// +/// Parameters for IaC rules template generation. +/// +public sealed class IaCRulesTemplateParameters +{ + public string DeploymentTool { get; set; } = string.Empty; + public string IacType { get; set; } = string.Empty; + public string[] ResourceTypes { get; set; } = []; + public string ResourceTypesDisplay { get; set; } = string.Empty; + public string DeploymentToolRules { get; set; } = string.Empty; + public string IacTypeRules { get; set; } = string.Empty; + public string ResourceSpecificRules { get; set; } = string.Empty; + public string FinalInstructions { get; set; } = string.Empty; + public string RequiredTools { get; set; } = string.Empty; + public string AdditionalNotes { get; set; } = string.Empty; + public string OutputFileName { get; set; } = string.Empty; + public string ContainerRegistryOutput { get; set; } = string.Empty; + public string RoleAssignmentResource { get; set; } = string.Empty; + public string ImageProperty { get; set; } = string.Empty; + public string CorsConfiguration { get; set; } = string.Empty; + public string LogAnalyticsConfiguration { get; set; } = string.Empty; + public string DiagnosticSettingsResource { get; set; } = string.Empty; + + /// + /// Converts the parameters to a dictionary for template processing. + /// + /// A dictionary with parameter names as keys and their values. + public Dictionary ToDictionary() + { + return new Dictionary + { + { nameof(DeploymentTool), DeploymentTool }, + { nameof(IacType), IacType }, + { nameof(ResourceTypesDisplay), ResourceTypesDisplay }, + { nameof(DeploymentToolRules), DeploymentToolRules }, + { nameof(IacTypeRules), IacTypeRules }, + { nameof(ResourceSpecificRules), ResourceSpecificRules }, + { nameof(FinalInstructions), FinalInstructions }, + { nameof(RequiredTools), RequiredTools }, + { nameof(AdditionalNotes), AdditionalNotes }, + { nameof(OutputFileName), OutputFileName }, + { nameof(ContainerRegistryOutput), ContainerRegistryOutput }, + { nameof(RoleAssignmentResource), RoleAssignmentResource }, + { nameof(ImageProperty), ImageProperty }, + { nameof(CorsConfiguration), CorsConfiguration }, + { nameof(LogAnalyticsConfiguration), LogAnalyticsConfiguration }, + { nameof(DiagnosticSettingsResource), DiagnosticSettingsResource }, + }; + } +} diff --git a/src/Areas/Deploy/Services/Templates/TemplateService.cs b/src/Areas/Deploy/Services/Templates/TemplateService.cs new file mode 100644 index 000000000..b691cefb0 --- /dev/null +++ b/src/Areas/Deploy/Services/Templates/TemplateService.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using System.Text; + +namespace AzureMcp.Areas.Deploy.Services.Templates; + +/// +/// Service for loading and processing embedded template resources. +/// +public static class TemplateService +{ + private static readonly Assembly _assembly = Assembly.GetExecutingAssembly(); + private const string TemplateNamespace = "AzureMcp.Areas.Deploy.Templates"; + + /// + /// Loads an embedded template resource by name. + /// + /// The name of the template file (without extension). + /// The template content as a string. + /// Thrown when the template is not found. + public static string LoadTemplate(string templateName) + { + string fileNamespace = TemplateNamespace; + if (templateName.Contains("/")) + { + fileNamespace += "." + templateName.Split("/")[0]; + templateName = templateName.Split("/")[1]; + } + var resourceName = $"{fileNamespace}.{templateName}.md"; + + using var stream = _assembly.GetManifestResourceStream(resourceName); + if (stream == null) + { + throw new FileNotFoundException($"Template '{templateName}' not found in embedded resources."); + } + + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + /// + /// Loads a template and replaces placeholders with provided values. + /// + /// The name of the template file (without extension). + /// Dictionary of placeholder names and their replacement values. + /// The processed template with placeholders replaced. + public static string ProcessTemplate(string templateName, Dictionary replacements) + { + var template = LoadTemplate(templateName); + return ProcessTemplateContent(template, replacements); + } + + /// + /// Processes template content by replacing placeholders with provided values. + /// + /// The template content to process. + /// Dictionary of placeholder names and their replacement values. + /// The processed template with placeholders replaced. + public static string ProcessTemplateContent(string templateContent, Dictionary replacements) + { + var result = new StringBuilder(templateContent); + + foreach (var (placeholder, value) in replacements) + { + var token = $"{{{{{placeholder}}}}}"; // {{placeholder}} + result.Replace(token, value); + } + + return result.ToString(); + } + +} diff --git a/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtilV2.cs b/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtilV2.cs new file mode 100644 index 000000000..dda3aed29 --- /dev/null +++ b/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtilV2.cs @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AzureMcp.Areas.Deploy.Models; +using AzureMcp.Areas.Deploy.Models.Templates; +using AzureMcp.Areas.Deploy.Services.Templates; + +namespace AzureMcp.Areas.Deploy.Services.Util; + +/// +/// Refactored utility class for generating deployment plan templates using embedded resources. +/// +public static class DeploymentPlanTemplateUtilV2 +{ + /// + /// Generates a deployment plan template using embedded template resources. + /// + /// The name of the project. Can be null or empty. + /// The target Azure service. + /// The provisioning tool. + /// The Infrastructure as Code options for AZD. + /// A formatted deployment plan template string. + public static string GetPlanTemplate(string projectName, string targetAppService, string provisioningTool, string? azdIacOptions = "") + { + // Default values for optional parameters + if (provisioningTool == "azd" && string.IsNullOrWhiteSpace(azdIacOptions)) + { + azdIacOptions = "bicep"; + } + + DeploymentPlanTemplateParameters parameters = CreateTemplateParameters(projectName, targetAppService, provisioningTool, azdIacOptions); + var executionSteps = GenerateExecutionSteps(parameters); + + parameters.ExecutionSteps = executionSteps; + + return TemplateService.ProcessTemplate("Plan/deployment-plan-base", parameters.ToDictionary()); + } + + /// + /// Creates template parameters from the provided inputs. + /// + private static DeploymentPlanTemplateParameters CreateTemplateParameters( + string projectName, + string targetAppService, + string provisioningTool, + string? azdIacOptions) + { + var azureComputeHost = GetAzureComputeHost(targetAppService); + var title = string.IsNullOrWhiteSpace(projectName) + ? "Azure Deployment Plan" + : $"Azure Deployment Plan for {projectName} Project"; + + return new DeploymentPlanTemplateParameters + { + Title = title, + ProjectName = projectName, + TargetAppService = targetAppService, + ProvisioningTool = provisioningTool, + IacType = azdIacOptions ?? "bicep", + AzureComputeHost = azureComputeHost, + }; + } + + /// + /// Gets the Azure compute host display name from the target app service. + /// + private static string GetAzureComputeHost(string targetAppService) + { + return targetAppService.ToLowerInvariant() switch + { + "containerapp" => "Azure Container Apps", + "webapp" => "Azure Web App Service", + "functionapp" => "Azure Functions", + "aks" => "Azure Kubernetes Service", + _ => "Azure Container Apps" + }; + } + + /// + /// Generates execution steps based on the deployment parameters. + /// + private static string GenerateExecutionSteps(DeploymentPlanTemplateParameters parameters) + { + var steps = new List(); + var isAks = parameters.TargetAppService.ToLowerInvariant() == "aks"; + + if (parameters.ProvisioningTool.ToLowerInvariant() == "azd") + { + steps.AddRange(GenerateAzdSteps(parameters, isAks)); + } + else if (parameters.ProvisioningTool.Equals(DeploymentTool.AzCli, StringComparison.OrdinalIgnoreCase)) + { + steps.AddRange(GenerateAzCliSteps(parameters, isAks)); + } + + return string.Join(Environment.NewLine, steps); + } + + /// + /// Generates AZD-specific execution steps. + /// + private static List GenerateAzdSteps(DeploymentPlanTemplateParameters parameters, bool isAks) + { + var steps = new List(); + + var deployTitle = isAks ? "" : " And Deploy the Application"; + var checkLog = isAks ? "" : "6. Check the application log with tool `azd-app-log-get` to ensure the services are running."; + + var azdStepReplacements = new Dictionary + { + { "DeployTitle", deployTitle }, + { "IacType", parameters.IacType }, + { "CheckLog", checkLog } + }; + + var azdSteps = TemplateService.ProcessTemplate("Plan/azd-steps", azdStepReplacements); + steps.Add(azdSteps); + + if (isAks) + { + steps.Add(TemplateService.LoadTemplate("Plan/aks-steps")); + steps.Add(TemplateService.ProcessTemplate("Plan/summary-steps", new Dictionary { { "StepNumber", "4" } })); + } + else + { + steps.Add(TemplateService.ProcessTemplate("Plan/summary-steps", new Dictionary { { "StepNumber", "2" } })); + } + + return steps; + } + + /// + /// Generates Azure CLI-specific execution steps. + /// + private static List GenerateAzCliSteps(DeploymentPlanTemplateParameters parameters, bool isAks) + { + var steps = new List(); + + steps.Add(TemplateService.LoadTemplate("Plan/azcli-steps")); + + if (isAks) + { + steps.Add(TemplateService.LoadTemplate("Plan/aks-steps")); + } + else + { + var isContainerApp = parameters.TargetAppService.ToLowerInvariant() == "containerapp"; + if (isContainerApp) + { + var containerAppReplacements = new Dictionary + { + { "AzureComputeHost", parameters.AzureComputeHost } + }; + steps.Add(TemplateService.ProcessTemplate("Plan/containerapp-steps", containerAppReplacements)); + } + else + { + // For other app services, generate basic deployment steps + var basicSteps = $""" + 2. Build and Deploy the Application: + 1. Deploy to {parameters.AzureComputeHost}: Use Azure CLI command to deploy the application + 3. Validation: + 1. Verify command output to ensure the application is deployed successfully + """; + steps.Add(basicSteps); + } + } + + steps.Add(TemplateService.ProcessTemplate("Plan/summary-steps", new Dictionary { { "StepNumber", "4" } })); + + return steps; + } +} diff --git a/src/Areas/Deploy/Services/Util/IaCRulesTemplateUtil.cs b/src/Areas/Deploy/Services/Util/IaCRulesTemplateUtil.cs new file mode 100644 index 000000000..8aae6ed05 --- /dev/null +++ b/src/Areas/Deploy/Services/Util/IaCRulesTemplateUtil.cs @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AzureMcp.Areas.Deploy.Models; +using AzureMcp.Areas.Deploy.Models.Templates; +using AzureMcp.Areas.Deploy.Services.Templates; + +namespace AzureMcp.Areas.Deploy.Services.Util; + +/// +/// Utility class for generating IaC rules using embedded templates. +/// +public static class IaCRulesTemplateUtil +{ + /// + /// Generates IaC rules using embedded templates. + /// + /// The deployment tool (azd, azcli). + /// The IaC type (bicep, terraform). + /// Array of resource types. + /// A formatted IaC rules string. + public static string GetIaCRules(string deploymentTool, string iacType, string[] resourceTypes) + { + // Default values for optional parameters + if (string.IsNullOrWhiteSpace(iacType)) + { + iacType = "bicep"; + } + + var parameters = CreateTemplateParameters(deploymentTool, iacType, resourceTypes); + var deploymentToolRules = GenerateDeploymentToolRules(parameters); + var iacTypeRules = GenerateIaCTypeRules(parameters); + var resourceSpecificRules = GenerateResourceSpecificRules(parameters); + var finalInstructions = GenerateFinalInstructions(parameters); + + parameters.DeploymentToolRules = deploymentToolRules; + parameters.IacTypeRules = iacTypeRules; + parameters.ResourceSpecificRules = resourceSpecificRules; + parameters.FinalInstructions = finalInstructions; + parameters.RequiredTools = BuildRequiredTools(deploymentTool, resourceTypes); + parameters.AdditionalNotes = BuildAdditionalNotes(deploymentTool, iacType); + + return TemplateService.ProcessTemplate("IaCRules/base-iac-rules", parameters.ToDictionary()); + } + + /// + /// Creates template parameters from the provided inputs. + /// + private static IaCRulesTemplateParameters CreateTemplateParameters( + string deploymentTool, + string iacType, + string[] resourceTypes) + { + var parameters = new IaCRulesTemplateParameters + { + DeploymentTool = deploymentTool, + IacType = iacType, + ResourceTypes = resourceTypes, + ResourceTypesDisplay = string.Join(", ", resourceTypes) + }; + + // Set IaC type specific parameters + SetIaCTypeSpecificParameters(parameters); + + return parameters; + } + + /// + /// Sets IaC type specific parameters. + /// + private static void SetIaCTypeSpecificParameters(IaCRulesTemplateParameters parameters) + { + parameters.OutputFileName = parameters.IacType == IacType.Bicep ? "main.bicep" : "outputs.tf"; + parameters.RoleAssignmentResource = parameters.IacType == IacType.Bicep + ? "Microsoft.Authorization/roleAssignments" + : "azurerm_role_assignment"; + parameters.ImageProperty = parameters.IacType == IacType.Bicep + ? "properties.template.containers.image" + : "azurerm_container_app.template.container.image"; + parameters.DiagnosticSettingsResource = parameters.IacType == IacType.Bicep + ? "Microsoft.Insights/diagnosticSettings" + : "azurerm_monitor_diagnostic_setting"; + + // Set CORS configuration based on IaC type + if (parameters.IacType == IacType.Bicep) + { + parameters.CorsConfiguration = "- Enable CORS via properties.configuration.ingress.corsPolicy."; + } + else if (parameters.IacType == IacType.Terraform) + { + parameters.CorsConfiguration = "- Create an ***azapi_resource_action*** resource using :type `Microsoft.App/containerApps`, method `PATCH`, and body `properties.configuration.ingress.corsPolicy` property to enable CORS for all origins, headers, and methods. Use 'azure/azapi' provider version *2.0*. DO NOT use jsonencode() for the body."; + } + + // Set Log Analytics configuration based on IaC type + if (parameters.IacType == IacType.Bicep) + { + parameters.LogAnalyticsConfiguration = "- Container App Environment must be connected to Log Analytics Workspace. Use logAnalyticsConfiguration -> customerId=logAnalytics.properties.customerId and sharedKey=logAnalytics.listKeys().primarySharedKey."; + } + else + { + parameters.LogAnalyticsConfiguration = "- Container App Environment must be connected to Log Analytics Workspace. Use logs_destination=\"log-analytics\" azurerm_container_app_environment.log_analytics_workspace_id = azurerm_log_analytics_workspace..id."; + } + } + + /// + /// Generates deployment tool specific rules. + /// + private static string GenerateDeploymentToolRules(IaCRulesTemplateParameters parameters) + { + if (parameters.DeploymentTool.Equals(DeploymentTool.Azd, StringComparison.OrdinalIgnoreCase)) + { + var containerRegistryOutput = parameters.ResourceTypes.Contains(AzureServiceNames.AzureContainerApp) + ? "\n- Expected output in " + parameters.OutputFileName + ": AZURE_CONTAINER_REGISTRY_ENDPOINT representing the URI of the container registry endpoint." + : string.Empty; + + var azdReplacements = new Dictionary + { + { "IacType", parameters.IacType }, + { "OutputFileName", parameters.OutputFileName }, + { "ContainerRegistryOutput", containerRegistryOutput } + }; + + return TemplateService.ProcessTemplate("IaCRules/azd-rules", azdReplacements); + } + else if (parameters.DeploymentTool.Equals(DeploymentTool.AzCli, StringComparison.OrdinalIgnoreCase)) + { + return TemplateService.LoadTemplate("IaCRules/azcli-rules"); + } + + return string.Empty; + } + + /// + /// Generates IaC type specific rules. + /// + private static string GenerateIaCTypeRules(IaCRulesTemplateParameters parameters) + { + return parameters.IacType switch + { + IacType.Bicep => TemplateService.LoadTemplate("IaCRules/bicep-rules"), + IacType.Terraform => TemplateService.LoadTemplate("IaCRules/terraform-rules"), + _ => string.Empty + }; + } + + /// + /// Generates resource specific rules. + /// + private static string GenerateResourceSpecificRules(IaCRulesTemplateParameters parameters) + { + var rules = new List(); + + if (parameters.ResourceTypes.Contains(AzureServiceNames.AzureContainerApp)) + { + rules.Add(TemplateService.ProcessTemplate("IaCRules/containerapp-rules", parameters.ToDictionary())); + } + + if (parameters.ResourceTypes.Contains(AzureServiceNames.AzureAppService)) + { + rules.Add(TemplateService.ProcessTemplate("IaCRules/appservice-rules", parameters.ToDictionary())); + } + + if (parameters.ResourceTypes.Contains(AzureServiceNames.AzureFunctionApp)) + { + rules.Add(TemplateService.ProcessTemplate("IaCRules/functionapp-rules", parameters.ToDictionary())); + } + + return string.Join(Environment.NewLine, rules); + } + + /// + /// Generates final instructions for the IaC rules. + /// + private static string GenerateFinalInstructions(IaCRulesTemplateParameters parameters) + { + return TemplateService.ProcessTemplate("IaCRules/final-instructions", parameters.ToDictionary()); + } + + /// + /// Builds the required tools list based on deployment tool and resource types. + /// + private static string BuildRequiredTools(string deploymentTool, string[] resourceTypes) + { + var tools = new List { "az cli (az --version)" }; + + if (deploymentTool == DeploymentTool.Azd) + { + tools.Add("azd (azd --version)"); + } + + if (resourceTypes.Contains(AzureServiceNames.AzureContainerApp)) + { + tools.Add("docker (docker --version)"); + } + + return string.Join(", ", tools) + "."; + } + + /// + /// Builds additional notes based on deployment tool and IaC type. + /// + private static string BuildAdditionalNotes(string deploymentTool, string iacType) + { + if (iacType == IacType.Terraform && deploymentTool == DeploymentTool.Azd) + { + return "Note: Do not use Terraform CLI."; + } + + return string.Empty; + } +} diff --git a/src/Areas/Deploy/Templates/IaCRules/appservice-rules.md b/src/Areas/Deploy/Templates/IaCRules/appservice-rules.md new file mode 100644 index 000000000..02b23b44e --- /dev/null +++ b/src/Areas/Deploy/Templates/IaCRules/appservice-rules.md @@ -0,0 +1,2 @@ +App Service Rules: +- App Service must be configured with appropriate settings. diff --git a/src/Areas/Deploy/Templates/IaCRules/azcli-rules.md b/src/Areas/Deploy/Templates/IaCRules/azcli-rules.md new file mode 100644 index 000000000..2a59cf271 --- /dev/null +++ b/src/Areas/Deploy/Templates/IaCRules/azcli-rules.md @@ -0,0 +1 @@ +- No additional rules. diff --git a/src/Areas/Deploy/Templates/IaCRules/azd-rules.md b/src/Areas/Deploy/Templates/IaCRules/azd-rules.md new file mode 100644 index 000000000..148423620 --- /dev/null +++ b/src/Areas/Deploy/Templates/IaCRules/azd-rules.md @@ -0,0 +1,5 @@ +- Ensure an User-Assigned Managed Identity exists. +- Resource Group resource (if exists) must have tag "azd-env-name" = environmentName. Apply this tag to resource group resource ONLY. +- Expected parameters in {{IacType}} parameters: environmentName='${{AZURE_ENV_NAME}}', location='${{AZURE_LOCATION}}'. resourceGroupName='rg-${{AZURE_ENV_NAME}}' is required if scope is subscription. +- All container apps, app services, function apps, static web apps (and nothing else) must have tag "azd-service-name" matching the service name in azure.yaml. +- Expected output in {{OutputFileName}}: RESOURCE_GROUP_ID.{{ContainerRegistryOutput}} diff --git a/src/Areas/Deploy/Templates/IaCRules/base-iac-rules.md b/src/Areas/Deploy/Templates/IaCRules/base-iac-rules.md new file mode 100644 index 000000000..4ea1663a7 --- /dev/null +++ b/src/Areas/Deploy/Templates/IaCRules/base-iac-rules.md @@ -0,0 +1,16 @@ +Mandatory rules for deployment. You must implement every rule exactly as stated, with no exceptions or omissions, even if it is not a common pattern or seems redundant. Do not use your own judgment to simplify, skip, or modify any rule. If a rule is present, it must be enforced in the code, regardless of context. Adjust {{IacType}} files to align with these rules. + +## Deployment Tool {{DeploymentTool}} rules: +{{DeploymentToolRules}} + +## IaC Type: {{IacType}} rules: +{{IacTypeRules}} + +## Resources: {{ResourceTypesDisplay}} +{{ResourceSpecificRules}} + +{{FinalInstructions}} + +## Tools needed: {{RequiredTools}} + +{{AdditionalNotes}} diff --git a/src/Areas/Deploy/Templates/IaCRules/bicep-rules.md b/src/Areas/Deploy/Templates/IaCRules/bicep-rules.md new file mode 100644 index 000000000..b2b7efb1f --- /dev/null +++ b/src/Areas/Deploy/Templates/IaCRules/bicep-rules.md @@ -0,0 +1,3 @@ +- Expected files: main.bicep, main.parameters.json (with parameters from main.bicep). +- Resource token format: 'uniqueString(subscription().id, resourceGroup().id, location, environmentName)' (scope = resourceGroup) or 'uniqueString(subscription().id, location, environmentName)' (scope = subscription). +- All resources should be named like az{resourcePrefix}{resourceToken}, where resourcePrefix is a prefix for the resource (ex. 'kv' for key vault) and <= 3 characters. Alphanumeric only. ResourceToken is the string generated by uniqueString as per earlier. diff --git a/src/Areas/Deploy/Templates/IaCRules/containerapp-rules.md b/src/Areas/Deploy/Templates/IaCRules/containerapp-rules.md new file mode 100644 index 000000000..1d0e10b61 --- /dev/null +++ b/src/Areas/Deploy/Templates/IaCRules/containerapp-rules.md @@ -0,0 +1,9 @@ +=== Additional requirements for Container Apps: +- Attach User-Assigned Managed Identity. +- MANDATORY: Add a {{RoleAssignmentResource}} resource to assign the AcrPull (7f951dda-4ed3-4680-a7ca-43fe172d538d) role to the user-assigned managed identity (Only one instance is required per-container registry. Define this BEFORE any container apps.). +- Use this identity (NOT system) to connect to the container registry. A registry connection needs to be created even if we are using a template base image. +- Container Apps MUST use base container image mcr.microsoft.com/azuredocs/containerapps-helloworld:latest. The property is set via {{ImageProperty}}. +{{CorsConfiguration}} +- Define all used secrets; Use Key Vault if possible. +{{LogAnalyticsConfiguration}} +=== diff --git a/src/Areas/Deploy/Templates/IaCRules/final-instructions.md b/src/Areas/Deploy/Templates/IaCRules/final-instructions.md new file mode 100644 index 000000000..5baae9f0a --- /dev/null +++ b/src/Areas/Deploy/Templates/IaCRules/final-instructions.md @@ -0,0 +1 @@ +Call get_errors every time you make code changes, otherwise your deployment will fail. You must follow ALL of the previously mentioned rules. DO NOT IGNORE ANY RULES. Call this tool again if need to get the rules again. Show the user a report line-by-line of each rule that was applied. Only skip a rule if there is no corresponding resource (e.g. no function app). Do not stop at error-free code, you must apply all the rules. diff --git a/src/Areas/Deploy/Templates/IaCRules/functionapp-rules.md b/src/Areas/Deploy/Templates/IaCRules/functionapp-rules.md new file mode 100644 index 000000000..aac1181e8 --- /dev/null +++ b/src/Areas/Deploy/Templates/IaCRules/functionapp-rules.md @@ -0,0 +1,10 @@ +=== Additional requirements for Function Apps: +- Attach User-Assigned Managed Identity. +- MANDATORY: Add a {{RoleAssignmentResource}} resource to assign the Storage Blob Data Owner (b7e6dc6d-f1e8-4753-8033-0f276bb0955b) role to the user-assigned managed identity. +- MANDATORY: Add a {{RoleAssignmentResource}} resource to assign the Storage Blob Data Contributor (ba92f5b4-2d11-453d-a403-e96b0029c9fe) role to the user-assigned managed identity. +- MANDATORY: Add a {{RoleAssignmentResource}} resource to assign the Storage Queue Data Contributor (974c5e8b-45b9-4653-ba55-5f855dd0fb88) role to the user-assigned managed identity. +- MANDATORY: Add a {{RoleAssignmentResource}} resource to assign the Storage Table Data Contributor (0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3) role to the user-assigned managed identity. +- MANDATORY: Add a {{RoleAssignmentResource}} resource to assign the Monitoring Metrics Publisher (3913510d-42f4-4e42-8a64-420c390055eb) role to the user-assigned managed identity. +- Create a storage account and connect to the function app. +- Define diagnostic settings to save logs. The resource type is {{DiagnosticSettingsResource}}. +=== diff --git a/src/Areas/Deploy/Templates/IaCRules/terraform-rules.md b/src/Areas/Deploy/Templates/IaCRules/terraform-rules.md new file mode 100644 index 000000000..25eeae467 --- /dev/null +++ b/src/Areas/Deploy/Templates/IaCRules/terraform-rules.md @@ -0,0 +1,2 @@ +- Expected files: main.tf, main.tfvars.json (with the minimally required parameters), outputs.tf. +- Resource names should use Azure CAF naming convention. This is required for deployments. Add aztfmod/azurecaf in the required provider. DO NOT use random_length. NO suffixes needed. diff --git a/src/Areas/Deploy/Templates/Plan/aks-steps.md b/src/Areas/Deploy/Templates/Plan/aks-steps.md new file mode 100644 index 000000000..74f84a41c --- /dev/null +++ b/src/Areas/Deploy/Templates/Plan/aks-steps.md @@ -0,0 +1,6 @@ +2. Build and Deploy the Application + 1. Build and Push Docker Image: {Agent should check if Dockerfile exists, if not add the step: "generate a Dockerfile for the application deployment", if does, list the Dockerfile path}. + 2. Prepare Kubernetes Manifests: {Agent should check if Kubernetes YAML files exists, if not add the step: "generate for the application deployment", if does, list the yaml files path}. + 3. Deploy to AKS: Use `kubectl apply` to deploy manifests to the AKS cluster +3. Validation: + 1. Verify pods are running and services are exposed diff --git a/src/Areas/Deploy/Templates/Plan/azcli-steps.md b/src/Areas/Deploy/Templates/Plan/azcli-steps.md new file mode 100644 index 000000000..f24f6fc26 --- /dev/null +++ b/src/Areas/Deploy/Templates/Plan/azcli-steps.md @@ -0,0 +1,4 @@ +1. Provision Azure Infrastructure: + 1. Generate Azure CLI scripts for required azure resources based on the plan. + 2. Check and fix the generated Azure CLI scripts for grammar errors. + 3. Run the Azure CLI scripts to provision the resources and confirm each resource is created or already exists diff --git a/src/Areas/Deploy/Templates/Plan/azd-steps.md b/src/Areas/Deploy/Templates/Plan/azd-steps.md new file mode 100644 index 000000000..a646fde8b --- /dev/null +++ b/src/Areas/Deploy/Templates/Plan/azd-steps.md @@ -0,0 +1,7 @@ +1. Provision Azure Infrastructure{{DeployTitle}}: + 1. Based on following required Azure resources in plan, get the IaC rules from the tool `iac-rules-get` + 2. Generate IaC ({{IacType}} files) for required azure resources based on the plan. + 3. Pre-check: use `get_errors` tool to check generated Bicep grammar errors. Fix the errors if exist. + 4. Run the AZD command `azd up` to provision the resources and confirm each resource is created or already exists. + 5. Check the deployment output to ensure the resources are provisioned successfully. + {{CheckLog}} diff --git a/src/Areas/Deploy/Templates/Plan/containerapp-steps.md b/src/Areas/Deploy/Templates/Plan/containerapp-steps.md new file mode 100644 index 000000000..976dd0eb1 --- /dev/null +++ b/src/Areas/Deploy/Templates/Plan/containerapp-steps.md @@ -0,0 +1,5 @@ +2. Build and Deploy the Application: + 1. Build and Push Docker Image: Agent should check if Dockerfile exists, if not add the step: 'generate a Dockerfile for the application deployment', if it does, list the Dockerfile path + 2. Deploy to {{AzureComputeHost}}: Use Azure CLI command to deploy the application +3. Validation: + 1. Verify command output to ensure the application is deployed successfully diff --git a/src/Areas/Deploy/Templates/Plan/deployment-plan-base.md b/src/Areas/Deploy/Templates/Plan/deployment-plan-base.md new file mode 100644 index 000000000..bd2e4676c --- /dev/null +++ b/src/Areas/Deploy/Templates/Plan/deployment-plan-base.md @@ -0,0 +1,60 @@ +# {{Title}} + +## **Goal** +Based on the project to provide a plan to deploy the project to Azure using {{ProvisioningTool}}. + +## **Project Information** +{ +briefly summarize the project structure, services, and configurations, example: +AppName: web +- **Technology Stack**: ASP.NET Core 7.0 Razor Pages application +- **Application Type**: Task Manager web application with client-side JavaScript +- **Containerization**: Ready for deployment with existing Dockerfile +- **Dependencies**: No external dependencies detected (database, APIs, etc.) +- **Hosting Recommendation**: Azure Container Apps for scalable, serverless container hosting +} + +## **Azure Resources Architecture** +> **Install the mermaid extension in IDE to view the architecture.** +{a mermaid graph of following recommended azure resource architecture. Only keep the most important edges to make structure clear and readable.} +{ +List how data flows between the services, example: +- The container app gets its image from the Azure Container Registry. +- The container app gets requests and interacts with the Azure SQL Database for data storage and retrieval. +} + +## **Recommended Azure Resources** + +Recommended App service hosting the project //agent should fulfill this for each app instance +- Application {{ProjectName}} + - Hosting Service Type: {{AzureComputeHost}} // it can be Azure Container Apps, Web App Service, Azure Functions, Azure Kubernetes Service. Recommend one based on the project. + - SKU // recommend a sku based on the project, show its performance. Don't estimate the cost. + - Configuration: + - language: {language} //detect from the project, it can be nodejs, python, dotnet, etc. + - dockerFilePath: {dockerFilePath}// fulfill this if service.azureComputeHost is ContainerApp + - dockerContext: {dockerContext}// fulfill this if service.azureComputeHost is ContainerApp + - Environment Variables: [] // the env variables that are used in the project/required by service + - Dependencies Resource + - Dependency Name + - SKU // recommend a sku, show its performance. + - Service Type // it can be Azure SQL, Azure Cosmos DB, Azure Storage, etc. + - Connection Type // it can be connection string, managed identity, etc. + - Environment Variables: [] // the env variables that are used in the project/required by dependency + +Recommended Supporting Services +- Application Insights +- Log Analytics Workspace: set all app service to connect to this +- Key Vault(Optional): If there are dependencies such as postgresql/sql/mysql, create a Key Vault to store connection string. If not, the resource should not show. +If there is a Container App, the following resources are required: +- Container Registry +If there is a WebApp(App Service): +- App Service Site Extension (Microsoft.Web/sites/siteextensions): Required for App Service deployments. + +Recommended Security Configurations +If there is a Container App +- User managed identity: Must be assigned to the container app. +- AcrPull role assignment: User managed identity must have **AcrPull** role ("7f951dda-4ed3-4680-a7ca-43fe172d538d") assigned to the container registry. + +## **Execution Step** +> **Below are the steps for Copilot to follow; ask Copilot to update or execute this plan.** +{{ExecutionSteps}} diff --git a/src/Areas/Deploy/Templates/Plan/summary-steps.md b/src/Areas/Deploy/Templates/Plan/summary-steps.md new file mode 100644 index 000000000..5ab29e67f --- /dev/null +++ b/src/Areas/Deploy/Templates/Plan/summary-steps.md @@ -0,0 +1,2 @@ +{{StepNumber}}: Summary: + 1. Summarize the deployment result and save to '.azure/summary.copilotmd'. It should list all changes deployment files and brief description of each file. Then have a diagram showing the provisioned azure resource. diff --git a/src/Areas/Quota/Commands/RegionCheckCommand.cs b/src/Areas/Quota/Commands/RegionCheckCommand.cs index 355122be2..d99a570fb 100644 --- a/src/Areas/Quota/Commands/RegionCheckCommand.cs +++ b/src/Areas/Quota/Commands/RegionCheckCommand.cs @@ -21,7 +21,7 @@ public sealed class RegionCheckCommand(ILogger logger) : Sub private readonly Option _cognitiveServiceModelVersionOption = QuotaOptionDefinitions.RegionCheck.CognitiveServiceModelVersion; private readonly Option _cognitiveServiceDeploymentSkuNameOption = QuotaOptionDefinitions.RegionCheck.CognitiveServiceDeploymentSkuName; - public override string Name => "available-region-get"; + public override string Name => "available-region-list"; public override string Description => """ diff --git a/src/Areas/Quota/QuotaSetup.cs b/src/Areas/Quota/QuotaSetup.cs index 1ca3a507b..a9ade11fb 100644 --- a/src/Areas/Quota/QuotaSetup.cs +++ b/src/Areas/Quota/QuotaSetup.cs @@ -22,6 +22,6 @@ public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactor rootGroup.AddSubGroup(quota); quota.AddCommand("usage-get", new UsageCheckCommand(loggerFactory.CreateLogger())); - quota.AddCommand("available-region-get", new RegionCheckCommand(loggerFactory.CreateLogger())); + quota.AddCommand("available-region-list", new RegionCheckCommand(loggerFactory.CreateLogger())); } } diff --git a/src/Areas/Quota/Services/Util/AzureUsageChecker.cs b/src/Areas/Quota/Services/Util/AzureUsageChecker.cs index 06b97e32d..48a63c289 100644 --- a/src/Areas/Quota/Services/Util/AzureUsageChecker.cs +++ b/src/Areas/Quota/Services/Util/AzureUsageChecker.cs @@ -37,7 +37,7 @@ public record UsageInfo( public interface IUsageChecker { - Task> GetQuotaForLocationAsync(string location); + Task> GetUsageForLocationAsync(string location); } // Abstract base class for checking Azure quotas @@ -56,7 +56,7 @@ protected AzureUsageChecker(TokenCredential credential, string subscriptionId) ResourceClient = new ArmClient(credential, subscriptionId); } - public abstract Task> GetQuotaForLocationAsync(string location); + public abstract Task> GetUsageForLocationAsync(string location); protected async Task GetQuotaByUrlAsync(string requestUrl) { @@ -148,7 +148,7 @@ public static async Task>> GetAzureQuotaAsync try { var usageChecker = UsageCheckerFactory.CreateUsageChecker(credential, provider, subscriptionId); - var quotaInfo = await usageChecker.GetQuotaForLocationAsync(location); + var quotaInfo = await usageChecker.GetUsageForLocationAsync(location); Console.WriteLine($"Quota info for provider {provider}: {quotaInfo.Count} items"); return resourceTypesForProvider.Select(rt => new KeyValuePair>(rt, quotaInfo)); diff --git a/src/Areas/Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs b/src/Areas/Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs index 101ca8cb8..1ae76d1e8 100644 --- a/src/Areas/Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs @@ -6,7 +6,7 @@ namespace AzureMcp.Areas.Quota.Services.Util; public class CognitiveServicesUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetUsageForLocationAsync(string location) { try { diff --git a/src/Areas/Quota/Services/Util/Usage/ComputeUsageChecker.cs b/src/Areas/Quota/Services/Util/Usage/ComputeUsageChecker.cs index 624c2d6ed..913aec5b0 100644 --- a/src/Areas/Quota/Services/Util/Usage/ComputeUsageChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/ComputeUsageChecker.cs @@ -7,7 +7,7 @@ namespace AzureMcp.Areas.Quota.Services.Util; public class ComputeUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetUsageForLocationAsync(string location) { try { diff --git a/src/Areas/Quota/Services/Util/Usage/ContainerAppUsageChecker.cs b/src/Areas/Quota/Services/Util/Usage/ContainerAppUsageChecker.cs index b62645617..f27a90df4 100644 --- a/src/Areas/Quota/Services/Util/Usage/ContainerAppUsageChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/ContainerAppUsageChecker.cs @@ -6,7 +6,7 @@ namespace AzureMcp.Areas.Quota.Services.Util; public class ContainerAppUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetUsageForLocationAsync(string location) { try { diff --git a/src/Areas/Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs b/src/Areas/Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs index 9881ce99a..df9a811d7 100644 --- a/src/Areas/Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs @@ -6,7 +6,7 @@ namespace AzureMcp.Areas.Quota.Services.Util; public class ContainerInstanceUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetUsageForLocationAsync(string location) { try { diff --git a/src/Areas/Quota/Services/Util/Usage/HDInsightUsageChecker.cs b/src/Areas/Quota/Services/Util/Usage/HDInsightUsageChecker.cs index 2fe97ac3a..fd159512f 100644 --- a/src/Areas/Quota/Services/Util/Usage/HDInsightUsageChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/HDInsightUsageChecker.cs @@ -6,7 +6,7 @@ namespace AzureMcp.Areas.Quota.Services.Util; public class HDInsightUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetUsageForLocationAsync(string location) { try { diff --git a/src/Areas/Quota/Services/Util/Usage/MachineLearningUsageChecker.cs b/src/Areas/Quota/Services/Util/Usage/MachineLearningUsageChecker.cs index e23658a8e..8630ccc87 100644 --- a/src/Areas/Quota/Services/Util/Usage/MachineLearningUsageChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/MachineLearningUsageChecker.cs @@ -5,7 +5,7 @@ namespace AzureMcp.Areas.Quota.Services.Util; public class MachineLearningUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetUsageForLocationAsync(string location) { try { diff --git a/src/Areas/Quota/Services/Util/Usage/NetworkUsageChecker.cs b/src/Areas/Quota/Services/Util/Usage/NetworkUsageChecker.cs index 1d342d39a..f9b5d1460 100644 --- a/src/Areas/Quota/Services/Util/Usage/NetworkUsageChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/NetworkUsageChecker.cs @@ -5,7 +5,7 @@ namespace AzureMcp.Areas.Quota.Services.Util; public class NetworkUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetUsageForLocationAsync(string location) { try { diff --git a/src/Areas/Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs b/src/Areas/Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs index 9fd5fd288..1af36bd98 100644 --- a/src/Areas/Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs @@ -5,7 +5,7 @@ namespace AzureMcp.Areas.Quota.Services.Util; public class PostgreSQLUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetUsageForLocationAsync(string location) { try { diff --git a/src/Areas/Quota/Services/Util/Usage/SearchUsageChecker.cs b/src/Areas/Quota/Services/Util/Usage/SearchUsageChecker.cs index db700a4d6..74cd9059d 100644 --- a/src/Areas/Quota/Services/Util/Usage/SearchUsageChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/SearchUsageChecker.cs @@ -6,7 +6,7 @@ namespace AzureMcp.Areas.Quota.Services.Util; public class SearchUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetUsageForLocationAsync(string location) { try { diff --git a/src/Areas/Quota/Services/Util/Usage/StorageUsageChecker.cs b/src/Areas/Quota/Services/Util/Usage/StorageUsageChecker.cs index 83a7a50f0..144321759 100644 --- a/src/Areas/Quota/Services/Util/Usage/StorageUsageChecker.cs +++ b/src/Areas/Quota/Services/Util/Usage/StorageUsageChecker.cs @@ -7,7 +7,7 @@ namespace AzureMcp.Areas.Quota.Services.Util; public class StorageUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { - public override async Task> GetQuotaForLocationAsync(string location) + public override async Task> GetUsageForLocationAsync(string location) { try { diff --git a/src/AzureMcp.csproj b/src/AzureMcp.csproj index e84dd608c..3f9609a6d 100644 --- a/src/AzureMcp.csproj +++ b/src/AzureMcp.csproj @@ -118,6 +118,7 @@ + diff --git a/tests/Areas/Deploy/LiveTests/DeployCommandTests.cs b/tests/Areas/Deploy/LiveTests/DeployCommandTests.cs index 10c0b4386..63e6cda87 100644 --- a/tests/Areas/Deploy/LiveTests/DeployCommandTests.cs +++ b/tests/Areas/Deploy/LiveTests/DeployCommandTests.cs @@ -56,7 +56,7 @@ public async Task Should_get_infrastructure_code_rules() // act var result = await CallToolMessageAsync( - "azmcp-deploy-infra-code-rules-get", + "azmcp-deploy-iac-rules-get", new() { { "deployment-tool", "azd" }, @@ -73,7 +73,7 @@ public async Task Should_get_infrastructure_rules_for_terraform() { // act var result = await CallToolMessageAsync( - "azmcp-deploy-infra-code-rules-get", + "azmcp-deploy-iac-rules-get", new() { { "deployment-tool", "azd" }, diff --git a/tests/Areas/Deploy/UnitTests/DeploymentPlanTemplateUtilV2Tests.cs b/tests/Areas/Deploy/UnitTests/DeploymentPlanTemplateUtilV2Tests.cs new file mode 100644 index 000000000..3f287a0c2 --- /dev/null +++ b/tests/Areas/Deploy/UnitTests/DeploymentPlanTemplateUtilV2Tests.cs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AzureMcp.Areas.Deploy.Services.Util; +using Xunit; + +namespace AzureMcp.Tests.Areas.Deploy.Services.Util; + +public sealed class DeploymentPlanTemplateUtilV2Tests +{ + [Theory] + [InlineData("TestProject", "ContainerApp", "AZD", "bicep")] + [InlineData("", "WebApp", "AzCli", "")] + [InlineData("MyApp", "AKS", "AZD", "terraform")] + public void GetPlanTemplate_ValidInputs_ReturnsFormattedTemplate( + string projectName, + string targetAppService, + string provisioningTool, + string azdIacOptions) + { + // Act + var result = DeploymentPlanTemplateUtilV2.GetPlanTemplate( + projectName, + targetAppService, + provisioningTool, + azdIacOptions); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + + // Should contain expected sections + Assert.Contains("## **Goal**", result); + Assert.Contains("## **Project Information**", result); + Assert.Contains("## **Azure Resources Architecture**", result); + Assert.Contains("## **Recommended Azure Resources**", result); + Assert.Contains("## **Execution Step**", result); + + // Should not contain unprocessed placeholders for main content + Assert.DoesNotContain("{{Title}}", result); + Assert.DoesNotContain("{{ProvisioningTool}}", result); + + // Should contain appropriate provisioning tool + if (provisioningTool.ToLowerInvariant() == "azd") + { + Assert.Contains("azd up", result); + } + else + { + Assert.Contains("Azure CLI", result); + } + } + + [Fact] + public void GetPlanTemplate_EmptyProjectName_UsesDefaultTitle() + { + // Act + var result = DeploymentPlanTemplateUtilV2.GetPlanTemplate( + "", + "ContainerApp", + "AZD", + "bicep"); + + // Assert + Assert.Contains("Azure Deployment Plan", result); + Assert.DoesNotContain("Azure Deployment Plan for Project", result); + } + + [Fact] + public void GetPlanTemplate_WithProjectName_UsesProjectSpecificTitle() + { + // Arrange + var projectName = "MyTestProject"; + + // Act + var result = DeploymentPlanTemplateUtilV2.GetPlanTemplate( + projectName, + "ContainerApp", + "AZD", + "bicep"); + + // Assert + Assert.Contains($"Azure Deployment Plan for {projectName} Project", result); + } + + [Theory] + [InlineData("containerapp", "Azure Container Apps")] + [InlineData("webapp", "Azure Web App Service")] + [InlineData("functionapp", "Azure Functions")] + [InlineData("aks", "Azure Kubernetes Service")] + [InlineData("unknown", "Azure Container Apps")] // Default case + public void GetPlanTemplate_DifferentTargetServices_MapsToCorrectAzureHost( + string targetAppService, + string expectedAzureHost) + { + // Act + var result = DeploymentPlanTemplateUtilV2.GetPlanTemplate( + "TestProject", + targetAppService, + "AZD", + "bicep"); + + // Assert + Assert.Contains(expectedAzureHost, result); + } + + [Fact] + public void GetPlanTemplate_AzdWithoutIacOptions_DefaultsToBicep() + { + // Act + var result = DeploymentPlanTemplateUtilV2.GetPlanTemplate( + "TestProject", + "ContainerApp", + "azd", + ""); + + // Assert + Assert.Contains("bicep", result); + } + + [Fact] + public void GetPlanTemplate_AksTarget_IncludesKubernetesSteps() + { + // Act + var result = DeploymentPlanTemplateUtilV2.GetPlanTemplate( + "TestProject", + "AKS", + "AZD", + "bicep"); + + // Assert + Assert.Contains("kubectl apply", result); + Assert.Contains("Kubernetes", result); + Assert.Contains("pods are running", result); + } + + [Fact] + public void GetPlanTemplate_ContainerAppWithAzCli_IncludesDockerSteps() + { + // Act + var result = DeploymentPlanTemplateUtilV2.GetPlanTemplate( + "TestProject", + "ContainerApp", + "AzCli", + ""); + + // Assert + Assert.Contains("Build and Push Docker Image", result); + Assert.Contains("Dockerfile", result); + } +} diff --git a/tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs b/tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs index fd8e8db02..0f4bd6e51 100644 --- a/tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs +++ b/tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs @@ -50,7 +50,7 @@ public async Task GetPlan_Should_Return_Expected_Result() Assert.NotNull(result); Assert.Equal(200, result.Status); Assert.NotNull(result.Message); - Assert.Contains("Title: Azure Deployment Plan for django Project", result.Message); + Assert.Contains("# Azure Deployment Plan for django Project", result.Message); Assert.Contains("Azure Container Apps", result.Message); } @@ -73,7 +73,7 @@ public async Task Should_get_plan_with_default_iac_options() Assert.NotNull(result); Assert.Equal(200, result.Status); Assert.NotNull(result.Message); - Assert.Contains("Title: Azure Deployment Plan for myapp Project", result.Message); + Assert.Contains("# Azure Deployment Plan for myapp Project", result.Message); Assert.Contains("Azure Web App Service", result.Message); } @@ -96,7 +96,7 @@ public async Task Should_get_plan_for_kubernetes() Assert.NotNull(result); Assert.Equal(200, result.Status); Assert.NotNull(result.Message); - Assert.Contains("Title: Azure Deployment Plan for k8s-app Project", result.Message); + Assert.Contains("# Azure Deployment Plan for k8s-app Project", result.Message); Assert.Contains("Azure Kubernetes Service", result.Message); } @@ -117,7 +117,7 @@ public async Task Should_get_plan_with_default_target_service() Assert.NotNull(result); Assert.Equal(200, result.Status); Assert.NotNull(result.Message); - Assert.Contains("Title: Azure Deployment Plan for default-app Project", result.Message); + Assert.Contains("# Azure Deployment Plan for default-app Project", result.Message); Assert.Contains("Azure Container Apps", result.Message); // Should default to Container Apps } } diff --git a/tests/Areas/Deploy/UnitTests/TemplateServiceTests.cs b/tests/Areas/Deploy/UnitTests/TemplateServiceTests.cs new file mode 100644 index 000000000..656b3d9d5 --- /dev/null +++ b/tests/Areas/Deploy/UnitTests/TemplateServiceTests.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AzureMcp.Areas.Deploy.Services.Templates; +using Xunit; + +namespace AzureMcp.Tests.Areas.Deploy.Services.Templates; + +public sealed class TemplateServiceTests +{ + [Fact] + public void LoadTemplate_ValidTemplate_ReturnsContent() + { + // Arrange + var templateName = "Plan/deployment-plan-base"; + + // Act + var result = TemplateService.LoadTemplate(templateName); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Contains("{{Title}}", result); + Assert.Contains("{{ProjectName}}", result); + } + + [Fact] + public void LoadTemplate_InvalidTemplate_ThrowsFileNotFoundException() + { + // Arrange + var templateName = "Plan/non-existent-template"; + + // Act & Assert + Assert.Throws(() => TemplateService.LoadTemplate(templateName)); + } + + [Fact] + public void ProcessTemplate_WithReplacements_ReplacesPlaceholders() + { + // Arrange + var templateName = "Plan/deployment-plan-base"; + var replacements = new Dictionary + { + { "Title", "Test Deployment Plan" }, + { "ProjectName", "TestProject" }, + { "ProvisioningTool", "AZD" } + }; + + // Act + var result = TemplateService.ProcessTemplate(templateName, replacements); + + // Assert + Assert.Contains("Test Deployment Plan", result); + Assert.Contains("TestProject", result); + Assert.Contains("AZD", result); + Assert.DoesNotContain("{{Title}}", result); + Assert.DoesNotContain("{{ProjectName}}", result); + Assert.DoesNotContain("{{ProvisioningTool}}", result); + } + + [Fact] + public void ProcessTemplateContent_WithReplacements_ReplacesPlaceholders() + { + // Arrange + var templateContent = "Hello {{Name}}, welcome to {{Project}}!"; + var replacements = new Dictionary + { + { "Name", "John" }, + { "Project", "Azure MCP" } + }; + + // Act + var result = TemplateService.ProcessTemplateContent(templateContent, replacements); + + // Assert + Assert.Equal("Hello John, welcome to Azure MCP!", result); + } + +} diff --git a/tests/Areas/Quota/LiveTests/QuotaCommandTests.cs b/tests/Areas/Quota/LiveTests/QuotaCommandTests.cs index 203611721..c1d5970fc 100644 --- a/tests/Areas/Quota/LiveTests/QuotaCommandTests.cs +++ b/tests/Areas/Quota/LiveTests/QuotaCommandTests.cs @@ -47,7 +47,7 @@ public async Task Should_check_azure_regions() { // act var result = await CallToolAsync( - "azmcp-quota-available-region-get", + "azmcp-quota-available-region-list", new() { { "subscription", _subscriptionId }, @@ -66,7 +66,7 @@ public async Task Should_check_regions_with_cognitive_services() { // act var result = await CallToolAsync( - "azmcp-quota-available-region-get", + "azmcp-quota-available-region-list", new() { { "subscription", _subscriptionId }, From 458cec41bd3ea907249cc15ac1010534c628356b Mon Sep 17 00:00:00 2001 From: xfz11 <81600993+xfz11@users.noreply.github.com> Date: Mon, 28 Jul 2025 05:24:00 +0000 Subject: [PATCH 12/56] clean code (#11) * clean code * update --- .github/CODEOWNERS | 1 + src/Areas/Deploy/Commands/PlanGetCommand.cs | 2 +- .../Util/DeploymentPlanTemplateUtil.cs | 253 +++++++++--------- .../Util/DeploymentPlanTemplateUtilV2.cs | 173 ------------ .../Deploy/Services/Util/IaCRuleRetriever.cs | 188 ------------- .../DeploymentPlanTemplateUtilV2Tests.cs | 14 +- 6 files changed, 131 insertions(+), 500 deletions(-) delete mode 100644 src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtilV2.cs delete mode 100644 src/Areas/Deploy/Services/Util/IaCRuleRetriever.cs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c0a3cbd40..673b85194 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -156,6 +156,7 @@ # PRLabel: %area-Deploy /src/Areas/Deploy/ @qianwens @xiaofanzhou @Azure/azure-mcp +/src/Areas/Quota/ @qianwens @xiaofanzhou @Azure/azure-mcp # ServiceLabel: %area-Deploy # ServiceOwners: @qianwens @xiaofanzhou diff --git a/src/Areas/Deploy/Commands/PlanGetCommand.cs b/src/Areas/Deploy/Commands/PlanGetCommand.cs index 38552ba9f..00c09261c 100644 --- a/src/Areas/Deploy/Commands/PlanGetCommand.cs +++ b/src/Areas/Deploy/Commands/PlanGetCommand.cs @@ -68,7 +68,7 @@ public override Task ExecuteAsync(CommandContext context, Parse return Task.FromResult(context.Response); } - var planTemplate = DeploymentPlanTemplateUtilV2.GetPlanTemplate(options.ProjectName, options.TargetAppService, options.ProvisioningTool, options.AzdIacOptions); + var planTemplate = DeploymentPlanTemplateUtil.GetPlanTemplate(options.ProjectName, options.TargetAppService, options.ProvisioningTool, options.AzdIacOptions); context.Response.Message = planTemplate; context.Response.Status = 200; diff --git a/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs b/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs index 60eab0e95..d64d97eec 100644 --- a/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs +++ b/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs @@ -2,18 +2,23 @@ // Licensed under the MIT License. using AzureMcp.Areas.Deploy.Models; +using AzureMcp.Areas.Deploy.Models.Templates; +using AzureMcp.Areas.Deploy.Services.Templates; namespace AzureMcp.Areas.Deploy.Services.Util; /// -/// Utility class for generating deployment plan templates. +/// Refactored utility class for generating deployment plan templates using embedded resources. /// public static class DeploymentPlanTemplateUtil { /// - /// Generates a deployment plan template with the specified project name. + /// Generates a deployment plan template using embedded template resources. /// /// The name of the project. Can be null or empty. + /// The target Azure service. + /// The provisioning tool. + /// The Infrastructure as Code options for AZD. /// A formatted deployment plan template string. public static string GetPlanTemplate(string projectName, string targetAppService, string provisioningTool, string? azdIacOptions = "") { @@ -22,7 +27,46 @@ public static string GetPlanTemplate(string projectName, string targetAppService { azdIacOptions = "bicep"; } - var azureComputeHost = targetAppService.ToLowerInvariant() switch + + DeploymentPlanTemplateParameters parameters = CreateTemplateParameters(projectName, targetAppService, provisioningTool, azdIacOptions); + var executionSteps = GenerateExecutionSteps(parameters); + + parameters.ExecutionSteps = executionSteps; + + return TemplateService.ProcessTemplate("Plan/deployment-plan-base", parameters.ToDictionary()); + } + + /// + /// Creates template parameters from the provided inputs. + /// + private static DeploymentPlanTemplateParameters CreateTemplateParameters( + string projectName, + string targetAppService, + string provisioningTool, + string? azdIacOptions) + { + var azureComputeHost = GetAzureComputeHost(targetAppService); + var title = string.IsNullOrWhiteSpace(projectName) + ? "Azure Deployment Plan" + : $"Azure Deployment Plan for {projectName} Project"; + + return new DeploymentPlanTemplateParameters + { + Title = title, + ProjectName = projectName, + TargetAppService = targetAppService, + ProvisioningTool = provisioningTool, + IacType = azdIacOptions ?? "bicep", + AzureComputeHost = azureComputeHost, + }; + } + + /// + /// Gets the Azure compute host display name from the target app service. + /// + private static string GetAzureComputeHost(string targetAppService) + { + return targetAppService.ToLowerInvariant() switch { "containerapp" => "Azure Container Apps", "webapp" => "Azure Web App Service", @@ -30,153 +74,100 @@ public static string GetPlanTemplate(string projectName, string targetAppService "aks" => "Azure Kubernetes Service", _ => "Azure Container Apps" }; + } - var aksDeploySteps = """ - 2. Build and Deploy the Application - 1. Build and Push Docker Image: {Agent should check if Dockerfile exists, if not add the step: "generate a Dockerfile for the application deployment", if does, list the Dockerfile path}. - 2. Prepare Kubernetes Manifests: {Agent should check if Kubernetes YAML files exists, if not add the step: "generate for the application deployment", if does, list the yaml files path}. - 3. Deploy to AKS: Use `kubectl apply` to deploy manifests to the AKS cluster - 3. Validation: - 1. Verify pods are running and services are exposed - """; + /// + /// Generates execution steps based on the deployment parameters. + /// + private static string GenerateExecutionSteps(DeploymentPlanTemplateParameters parameters) + { + var steps = new List(); + var isAks = parameters.TargetAppService.ToLowerInvariant() == "aks"; - var summary = "Summarize the deployment result and save to '.azure/summary.copilotmd'. It should list all changes deployment files and brief description of each file. Then have a diagram showing the provisioned azure resource."; + if (parameters.ProvisioningTool.ToLowerInvariant() == "azd") + { + steps.AddRange(GenerateAzdSteps(parameters, isAks)); + } + else if (parameters.ProvisioningTool.Equals(DeploymentTool.AzCli, StringComparison.OrdinalIgnoreCase)) + { + steps.AddRange(GenerateAzCliSteps(parameters, isAks)); + } + + return string.Join(Environment.NewLine, steps); + } + + /// + /// Generates AZD-specific execution steps. + /// + private static List GenerateAzdSteps(DeploymentPlanTemplateParameters parameters, bool isAks) + { var steps = new List(); + + var deployTitle = isAks ? "" : " And Deploy the Application"; + var checkLog = isAks ? "" : "6. Check the application log with tool `azd-app-log-get` to ensure the services are running."; - if (provisioningTool.ToLowerInvariant() == "azd") + var azdStepReplacements = new Dictionary { - var deployTitle = targetAppService.ToLowerInvariant() == "aks" - ? "" - : " And Deploy the Application"; - var checkLog = targetAppService.ToLowerInvariant() == "aks" - ? "" - : "6. Check the application log with tool `azd-app-log-get` to ensure the services are running."; - steps.Add($""" - 1. Provision Azure Infrastructure{deployTitle}: - 1. Based on following required Azure resources in plan, get the IaC rules from the tool `iac-rules-get` - 2. Generate IaC ({azdIacOptions} files) for required azure resources based on the plan. - 3. Pre-check: use `get_errors` tool to check generated Bicep grammar errors. Fix the errors if exist. - 4. Run the AZD command `azd up` to provision the resources and confirm each resource is created or already exists. - 5. Check the deployment output to ensure the resources are provisioned successfully. - {checkLog} - """); - if (targetAppService.ToLowerInvariant() == "aks") - { - steps.Add(aksDeploySteps); - steps.Add($$""" - 4: Summary: - 1. {{summary}} - """); - } - else - { - steps.Add($$""" - 2: Summary: - 1. {{summary}} - """); - } + { "DeployTitle", deployTitle }, + { "IacType", parameters.IacType }, + { "CheckLog", checkLog } + }; + + var azdSteps = TemplateService.ProcessTemplate("Plan/azd-steps", azdStepReplacements); + steps.Add(azdSteps); + + if (isAks) + { + steps.Add(TemplateService.LoadTemplate("Plan/aks-steps")); + steps.Add(TemplateService.ProcessTemplate("Plan/summary-steps", new Dictionary { { "StepNumber", "4" } })); + } + else + { + steps.Add(TemplateService.ProcessTemplate("Plan/summary-steps", new Dictionary { { "StepNumber", "2" } })); + } + + return steps; + } + + /// + /// Generates Azure CLI-specific execution steps. + /// + private static List GenerateAzCliSteps(DeploymentPlanTemplateParameters parameters, bool isAks) + { + var steps = new List(); + steps.Add(TemplateService.LoadTemplate("Plan/azcli-steps")); + if (isAks) + { + steps.Add(TemplateService.LoadTemplate("Plan/aks-steps")); } - else if (provisioningTool.Equals(DeploymentTool.AzCli, StringComparison.OrdinalIgnoreCase)) + else { - steps.Add(""" - 1. Provision Azure Infrastructure: - 1. Generate Azure CLI scripts for required azure resources based on the plan. - 2. Check and fix the generated Azure CLI scripts for grammar errors. - 3. Run the Azure CLI scripts to provision the resources and confirm each resource is created or already exists - """); - if (targetAppService.ToLowerInvariant() == "aks") + var isContainerApp = parameters.TargetAppService.ToLowerInvariant() == "containerapp"; + if (isContainerApp) { - steps.Add(aksDeploySteps); + var containerAppReplacements = new Dictionary + { + { "AzureComputeHost", parameters.AzureComputeHost } + }; + steps.Add(TemplateService.ProcessTemplate("Plan/containerapp-steps", containerAppReplacements)); } else { - var isContainerApp = targetAppService.ToLowerInvariant() == "containerapp"; - var containerAppOptions = isContainerApp ? " 1. Build and Push Docker Image: Agent should check if Dockerfile exists, if not add the step: 'generate a Dockerfile for the application deployment', if it does, list the Dockerfile path" : ""; - var orderList = isContainerApp ? "2." : "1."; - steps.Add($$""" + // For other app services, generate basic deployment steps + var basicSteps = $""" 2. Build and Deploy the Application: - {{containerAppOptions}} - {{orderList}} Deploy to {{azureComputeHost}}: Use Azure CLI command to deploy the application + 1. Deploy to {parameters.AzureComputeHost}: Use Azure CLI command to deploy the application 3. Validation: 1. Verify command output to ensure the application is deployed successfully - """); + """; + steps.Add(basicSteps); } - steps.Add($$""" - 4: Summary: - 1 {{summary}} - """); } - var title = string.IsNullOrWhiteSpace(projectName) - ? "Azure Deployment Plan" - : $"Azure Deployment Plan for {projectName} Project"; - - return $$""" -{Agent should fill in and polish the markdown template below to generate a deployment plan for the project. Then save it to '.azure/plan.copilotmd' file. Don't add cost estimation! Don't add extra validation steps unless it is required! Don't change the tool name!} - -#Title: {{title}} -## **Goal** -Based on the project to provide a plan to deploy the project to Azure using AZD. It will generate Bicep files and Azure YAML configuration. - - -## **Project Information** -{ -briefly summarize the project structure, services, and configurations, example: -AppName: web -- **Technology Stack**: ASP.NET Core 7.0 Razor Pages application -- **Application Type**: Task Manager web application with client-side JavaScript -- **Containerization**: Ready for deployment with existing Dockerfile -- **Dependencies**: No external dependencies detected (database, APIs, etc.) -- **Hosting Recommendation**: Azure Container Apps for scalable, serverless container hosting -} - -## **Azure Resources Architecture** -> **Install the mermaid extension in IDE to view the architecture.** -{a mermaid graph of following recommended azure resource architecture. Only keep the most important edges to make structure clear and readable.} -{ -List how data flows between the services, example: -- The container app gets its image from the Azure Container Registry. -- The container app gets requests and interacts with the Azure SQL Database for data storage and retrieval. -} + steps.Add(TemplateService.ProcessTemplate("Plan/summary-steps", new Dictionary { { "StepNumber", "4" } })); -## **Recommended Azure Resources** - -Recommended App service hosting the project //agent should fulfill this for each app instance -- Application {{projectName}} - - Hosting Service Type: {{azureComputeHost}} // it can be Azure Container Apps, Web App Service, Azure Functions, Azure Kubernetes Service. Recommend one based on the project. - - SKU // recommend a sku based on the project, show its performance. Don't estimate the cost. - - Configuration: - - language: {language} //detect from the project, it can be nodejs, python, dotnet, etc. - - dockerFilePath: {dockerFilePath}// fulfill this if service.azureComputeHost is ContainerApp - - dockerContext: {dockerContext}// fulfill this if service.azureComputeHost is ContainerApp - - Environment Variables: [] // the env variables that are used in the project/required by service - - Dependencies Resource - - Dependency Name - - SKU // recommend a sku, show its performance. - - Service Type // it can be Azure SQL, Azure Cosmos DB, Azure Storage, etc. - - Connection Type // it can be connection string, managed identity, etc. - - Environment Variables: [] // the env variables that are used in the project/required by dependency - -Recommended Supporting Services -- Application Insights -- Log Analytics Workspace: set all app service to connect to this -- Key Vault(Optional): If there are dependencies such as postgresql/sql/mysql, create a Key Vault to store connection string. If not, the resource should not show. -If there is a Container App, the following resources are required: -- Container Registry -If there is a WebApp(App Service): -- App Service Site Extension (Microsoft.Web/sites/siteextensions): Required for App Service deployments. - -Recommended Security Configurations -If there is a Container App -- User managed identity: Must be assigned to the container app. -- AcrPull role assignment: User managed identity must have **AcrPull** role ("7f951dda-4ed3-4680-a7ca-43fe172d538d") assigned to the container registry. - -## **Execution Step** -> **Below are the steps for Copilot to follow; ask Copilot to update or execute this plan.** -{{string.Join(Environment.NewLine, steps)}} - -"""; + return steps; } } diff --git a/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtilV2.cs b/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtilV2.cs deleted file mode 100644 index dda3aed29..000000000 --- a/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtilV2.cs +++ /dev/null @@ -1,173 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using AzureMcp.Areas.Deploy.Models; -using AzureMcp.Areas.Deploy.Models.Templates; -using AzureMcp.Areas.Deploy.Services.Templates; - -namespace AzureMcp.Areas.Deploy.Services.Util; - -/// -/// Refactored utility class for generating deployment plan templates using embedded resources. -/// -public static class DeploymentPlanTemplateUtilV2 -{ - /// - /// Generates a deployment plan template using embedded template resources. - /// - /// The name of the project. Can be null or empty. - /// The target Azure service. - /// The provisioning tool. - /// The Infrastructure as Code options for AZD. - /// A formatted deployment plan template string. - public static string GetPlanTemplate(string projectName, string targetAppService, string provisioningTool, string? azdIacOptions = "") - { - // Default values for optional parameters - if (provisioningTool == "azd" && string.IsNullOrWhiteSpace(azdIacOptions)) - { - azdIacOptions = "bicep"; - } - - DeploymentPlanTemplateParameters parameters = CreateTemplateParameters(projectName, targetAppService, provisioningTool, azdIacOptions); - var executionSteps = GenerateExecutionSteps(parameters); - - parameters.ExecutionSteps = executionSteps; - - return TemplateService.ProcessTemplate("Plan/deployment-plan-base", parameters.ToDictionary()); - } - - /// - /// Creates template parameters from the provided inputs. - /// - private static DeploymentPlanTemplateParameters CreateTemplateParameters( - string projectName, - string targetAppService, - string provisioningTool, - string? azdIacOptions) - { - var azureComputeHost = GetAzureComputeHost(targetAppService); - var title = string.IsNullOrWhiteSpace(projectName) - ? "Azure Deployment Plan" - : $"Azure Deployment Plan for {projectName} Project"; - - return new DeploymentPlanTemplateParameters - { - Title = title, - ProjectName = projectName, - TargetAppService = targetAppService, - ProvisioningTool = provisioningTool, - IacType = azdIacOptions ?? "bicep", - AzureComputeHost = azureComputeHost, - }; - } - - /// - /// Gets the Azure compute host display name from the target app service. - /// - private static string GetAzureComputeHost(string targetAppService) - { - return targetAppService.ToLowerInvariant() switch - { - "containerapp" => "Azure Container Apps", - "webapp" => "Azure Web App Service", - "functionapp" => "Azure Functions", - "aks" => "Azure Kubernetes Service", - _ => "Azure Container Apps" - }; - } - - /// - /// Generates execution steps based on the deployment parameters. - /// - private static string GenerateExecutionSteps(DeploymentPlanTemplateParameters parameters) - { - var steps = new List(); - var isAks = parameters.TargetAppService.ToLowerInvariant() == "aks"; - - if (parameters.ProvisioningTool.ToLowerInvariant() == "azd") - { - steps.AddRange(GenerateAzdSteps(parameters, isAks)); - } - else if (parameters.ProvisioningTool.Equals(DeploymentTool.AzCli, StringComparison.OrdinalIgnoreCase)) - { - steps.AddRange(GenerateAzCliSteps(parameters, isAks)); - } - - return string.Join(Environment.NewLine, steps); - } - - /// - /// Generates AZD-specific execution steps. - /// - private static List GenerateAzdSteps(DeploymentPlanTemplateParameters parameters, bool isAks) - { - var steps = new List(); - - var deployTitle = isAks ? "" : " And Deploy the Application"; - var checkLog = isAks ? "" : "6. Check the application log with tool `azd-app-log-get` to ensure the services are running."; - - var azdStepReplacements = new Dictionary - { - { "DeployTitle", deployTitle }, - { "IacType", parameters.IacType }, - { "CheckLog", checkLog } - }; - - var azdSteps = TemplateService.ProcessTemplate("Plan/azd-steps", azdStepReplacements); - steps.Add(azdSteps); - - if (isAks) - { - steps.Add(TemplateService.LoadTemplate("Plan/aks-steps")); - steps.Add(TemplateService.ProcessTemplate("Plan/summary-steps", new Dictionary { { "StepNumber", "4" } })); - } - else - { - steps.Add(TemplateService.ProcessTemplate("Plan/summary-steps", new Dictionary { { "StepNumber", "2" } })); - } - - return steps; - } - - /// - /// Generates Azure CLI-specific execution steps. - /// - private static List GenerateAzCliSteps(DeploymentPlanTemplateParameters parameters, bool isAks) - { - var steps = new List(); - - steps.Add(TemplateService.LoadTemplate("Plan/azcli-steps")); - - if (isAks) - { - steps.Add(TemplateService.LoadTemplate("Plan/aks-steps")); - } - else - { - var isContainerApp = parameters.TargetAppService.ToLowerInvariant() == "containerapp"; - if (isContainerApp) - { - var containerAppReplacements = new Dictionary - { - { "AzureComputeHost", parameters.AzureComputeHost } - }; - steps.Add(TemplateService.ProcessTemplate("Plan/containerapp-steps", containerAppReplacements)); - } - else - { - // For other app services, generate basic deployment steps - var basicSteps = $""" - 2. Build and Deploy the Application: - 1. Deploy to {parameters.AzureComputeHost}: Use Azure CLI command to deploy the application - 3. Validation: - 1. Verify command output to ensure the application is deployed successfully - """; - steps.Add(basicSteps); - } - } - - steps.Add(TemplateService.ProcessTemplate("Plan/summary-steps", new Dictionary { { "StepNumber", "4" } })); - - return steps; - } -} diff --git a/src/Areas/Deploy/Services/Util/IaCRuleRetriever.cs b/src/Areas/Deploy/Services/Util/IaCRuleRetriever.cs deleted file mode 100644 index a1b47afc1..000000000 --- a/src/Areas/Deploy/Services/Util/IaCRuleRetriever.cs +++ /dev/null @@ -1,188 +0,0 @@ -using AzureMcp.Areas.Deploy.Models; - -namespace AzureMcp.Areas.Deploy.Services.Util; - -public static class InfraCodeRuleRetriever -{ - public static void PopulateAZDPrompts(string iacType, string[] resourceTypes, List llmResponse) - { - llmResponse.Add("- Ensure an User-Assigned Managed Identity exists."); - llmResponse.Add("- Resource Group resource (if exists) must have tag \"azd-env-name\" = environmentName. Apply this tag to resource group resource ONLY."); - llmResponse.Add($"- Expected parameters in {iacType} parameters: environmentName='${{AZURE_ENV_NAME}}', location='${{AZURE_LOCATION}}'. resourceGroupName='rg-${{AZURE_ENV_NAME}}' is required if scope is subscription."); - llmResponse.Add("- All container apps, app services, function apps, static web apps (and nothing else) must have tag \"azd-service-name\" matching the service name in azure.yaml."); - - var outputsFileName = iacType == IacType.Bicep ? "main.bicep" : "outputs.tf"; - llmResponse.Add($"- Expected output in {outputsFileName}: RESOURCE_GROUP_ID."); - if (resourceTypes.Contains(AzureServiceNames.AzureContainerApp)) - { - llmResponse.Add($"- Expected output in {outputsFileName}: AZURE_CONTAINER_REGISTRY_ENDPOINT representing the URI of the container registry endpoint."); - } - } - - public static void PopulateAzCliPrompts(List llmResponse) - { - // TODO: Enrich Me - llmResponse.Add("- No additional rules."); - } - - public static void PopulateBicepPrompts(List llmResponse) - { - llmResponse.Add("- Expected files: main.bicep, main.parameters.json (with parameters from main.bicep)."); - llmResponse.Add("- Resource token format: 'uniqueString(subscription().id, resourceGroup().id, location, environmentName)' (scope = resourceGroup) or 'uniqueString(subscription().id, location, environmentName)' (scope = subscription)."); - llmResponse.Add("- All resources should be named like az{resourcePrefix}{resourceToken}, where resourcePrefix is a prefix for the resource (ex. 'kv' for key vault) and <= 3 characters. Alphanumeric only. ResourceToken is the string generated by uniqueString as per earlier."); - } - - public static void PopulateTerraformPrompts(List llmResponse) - { - llmResponse.Add("- Expected files: main.tf, main.tfvars.json (with the minimally required parameters), outputs.tf."); - llmResponse.Add("- Resource names should use Azure CAF naming convention. This is required for deployments. Add aztfmod/azurecaf in the required provider. DO NOT use random_length. NO suffixes needed."); - } - - public static string AddPromptForRoleAssignment(string roleId, string roleName, string iacType, string? additionalInstructions = null) - { - var roleAssignmentResourceName = iacType == IacType.Bicep ? "Microsoft.Authorization/roleAssignments" : "azurerm_role_assignment"; - - var returnString = $"- MANDATORY: Add a {roleAssignmentResourceName} resource to assign the {roleName} ({roleId}) role to the user-assigned managed identity"; - - if (!string.IsNullOrEmpty(additionalInstructions)) - { - returnString += $" ({additionalInstructions})"; - } - - return returnString; - } - - public static void PopulateContainerAppPrompts(string iacType, List llmResponse) - { - llmResponse.Add("=== Additional requirements for Container Apps:"); - llmResponse.Add("- Attach User-Assigned Managed Identity."); - llmResponse.Add(AddPromptForRoleAssignment("7f951dda-4ed3-4680-a7ca-43fe172d538d", "AcrPull", iacType, "Only one instance is required per-container registry. Define this BEFORE any container apps.")); - llmResponse.Add("- Use this identity (NOT system) to connect to the container registry. A registry connection needs to be created even if we are using a template base image."); - - const string image = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest"; - var imageProperty = iacType == IacType.Bicep ? "properties.template.containers.image" : "azurerm_container_app.template.container.image"; - llmResponse.Add($"- Container Apps MUST use base container image {image}. The property is set via {imageProperty}."); - - if (iacType == IacType.Bicep) - { - llmResponse.Add("- Enable CORS via properties.configuration.ingress.corsPolicy."); - } - else if (iacType == IacType.Terraform) - { - llmResponse.Add("- Create an ***azapi_resource_action*** resource using :type `Microsoft.App/containerApps`, method `PATCH`, and body `properties.configuration.ingress.corsPolicy` property to enable CORS for all origins, headers, and methods. Use 'azure/azapi' provider version *2.0*. DO NOT use jsonencode() for the body."); - } - - llmResponse.Add("- Define all used secrets; Use Key Vault if possible."); - - if (iacType == IacType.Bicep) - { - llmResponse.Add("- Container App Environment must be connected to Log Analytics Workspace. Use logAnalyticsConfiguration -> customerId=logAnalytics.properties.customerId and sharedKey=logAnalytics.listKeys().primarySharedKey."); - } - else - { - llmResponse.Add("- Container App Environment must be connected to Log Analytics Workspace. Use logs_destination=\"log-analytics\" azurerm_container_app_environment.log_analytics_workspace_id = azurerm_log_analytics_workspace..id."); - } - llmResponse.Add("==="); - } - - public static void PopulateFunctionAppPrompts(string iacType, List llmResponse) - { - llmResponse.Add("=== Additional requirements for Function Apps:"); - llmResponse.Add("- Attach User-Assigned Managed Identity."); - - var requiredRoles = new[] - { - new { RoleId = "b7e6dc6d-f1e8-4753-8033-0f276bb0955b", Name = "Storage Blob Data Owner" }, - new { RoleId = "ba92f5b4-2d11-453d-a403-e96b0029c9fe", Name = "Storage Blob Data Contributor" }, - new { RoleId = "974c5e8b-45b9-4653-ba55-5f855dd0fb88", Name = "Storage Queue Data Contributor" }, - new { RoleId = "0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3", Name = "Storage Table Data Contributor" }, - new { RoleId = "3913510d-42f4-4e42-8a64-420c390055eb", Name = "Monitoring Metrics Publisher" } - }; - - foreach (var role in requiredRoles) - { - llmResponse.Add(AddPromptForRoleAssignment(role.RoleId, role.Name, iacType)); - } - - llmResponse.Add("- Create a storage account and connect to the function app."); - - var diagnosticSettingsResourceType = iacType == IacType.Bicep ? "Microsoft.Insights/diagnosticSettings" : "azurerm_monitor_diagnostic_setting"; - llmResponse.Add($"Define diagnostic settings to save logs. The resource type is {diagnosticSettingsResourceType}."); - llmResponse.Add("==="); - } - - public static void PopulateAppServiceIaCPrompts(string iacType, List llmResponse) - { - llmResponse.Add("App Service Rules:"); - llmResponse.Add("- App Service must be configured with appropriate settings."); - } - - public static List GetIaCRules(string deploymentTool, string iacType, string[] resourceTypes) - { - var llmResponse = new List - { - $"Mandatory rules for deployment. You must implement every rule exactly as stated, with no exceptions or omissions, even if it is not a common pattern or seems redundant. Do not use your own judgment to simplify, skip, or modify any rule. If a rule is present, it must be enforced in the code, regardless of context. Adjust {iacType} files to align with these rules.", - $"Deployment Tool {deploymentTool} rules:" - }; - - if (deploymentTool.Equals(DeploymentTool.Azd, StringComparison.OrdinalIgnoreCase)) - { - PopulateAZDPrompts(iacType, resourceTypes, llmResponse); - } - else if (deploymentTool.Equals(DeploymentTool.AzCli, StringComparison.OrdinalIgnoreCase)) - { - PopulateAzCliPrompts(llmResponse); - } - - llmResponse.Add($"IaC Type: {iacType} rules:"); - - if (iacType == IacType.Bicep) - { - PopulateBicepPrompts(llmResponse); - } - else if (iacType == IacType.Terraform) - { - PopulateTerraformPrompts(llmResponse); - } - - llmResponse.Add($"Resources: {string.Join(", ", resourceTypes)}"); - - if (resourceTypes.Contains(AzureServiceNames.AzureContainerApp)) - { - PopulateContainerAppPrompts(iacType, llmResponse); - } - - if (resourceTypes.Contains(AzureServiceNames.AzureAppService)) - { - PopulateAppServiceIaCPrompts(iacType, llmResponse); - } - - if (resourceTypes.Contains(AzureServiceNames.AzureFunctionApp)) - { - PopulateFunctionAppPrompts(iacType, llmResponse); - } - - llmResponse.Add($"Call get_errors every time you make code changes, otherwise your deployment will fail. You must follow ALL of the previously mentioned rules. DO NOT IGNORE ANY RULES. Call this tool again if need to get the rules again. Show the user a report line-by-line of each rule that was applied. Only skip a rule if there is no corresponding resource (e.g. no function app). Do not stop at error-free code, you must apply all the rules."); - - var necessaryTools = new List { "az cli (az --version)" }; - - if (deploymentTool == DeploymentTool.Azd) - { - necessaryTools.Add("azd (azd --version)"); - } - - if (resourceTypes.Contains(AzureServiceNames.AzureContainerApp)) - { - necessaryTools.Add("docker (docker --version)"); - } - - llmResponse.Add($"Tools needed: {string.Join(",", necessaryTools)}."); - - if (iacType == IacType.Terraform && deploymentTool == DeploymentTool.Azd) - { - llmResponse.Add("Note: Do not use Terraform CLI."); - } - - return llmResponse; - } -} diff --git a/tests/Areas/Deploy/UnitTests/DeploymentPlanTemplateUtilV2Tests.cs b/tests/Areas/Deploy/UnitTests/DeploymentPlanTemplateUtilV2Tests.cs index 3f287a0c2..5c7e17837 100644 --- a/tests/Areas/Deploy/UnitTests/DeploymentPlanTemplateUtilV2Tests.cs +++ b/tests/Areas/Deploy/UnitTests/DeploymentPlanTemplateUtilV2Tests.cs @@ -19,7 +19,7 @@ public void GetPlanTemplate_ValidInputs_ReturnsFormattedTemplate( string azdIacOptions) { // Act - var result = DeploymentPlanTemplateUtilV2.GetPlanTemplate( + var result = DeploymentPlanTemplateUtil.GetPlanTemplate( projectName, targetAppService, provisioningTool, @@ -55,7 +55,7 @@ public void GetPlanTemplate_ValidInputs_ReturnsFormattedTemplate( public void GetPlanTemplate_EmptyProjectName_UsesDefaultTitle() { // Act - var result = DeploymentPlanTemplateUtilV2.GetPlanTemplate( + var result = DeploymentPlanTemplateUtil.GetPlanTemplate( "", "ContainerApp", "AZD", @@ -73,7 +73,7 @@ public void GetPlanTemplate_WithProjectName_UsesProjectSpecificTitle() var projectName = "MyTestProject"; // Act - var result = DeploymentPlanTemplateUtilV2.GetPlanTemplate( + var result = DeploymentPlanTemplateUtil.GetPlanTemplate( projectName, "ContainerApp", "AZD", @@ -94,7 +94,7 @@ public void GetPlanTemplate_DifferentTargetServices_MapsToCorrectAzureHost( string expectedAzureHost) { // Act - var result = DeploymentPlanTemplateUtilV2.GetPlanTemplate( + var result = DeploymentPlanTemplateUtil.GetPlanTemplate( "TestProject", targetAppService, "AZD", @@ -108,7 +108,7 @@ public void GetPlanTemplate_DifferentTargetServices_MapsToCorrectAzureHost( public void GetPlanTemplate_AzdWithoutIacOptions_DefaultsToBicep() { // Act - var result = DeploymentPlanTemplateUtilV2.GetPlanTemplate( + var result = DeploymentPlanTemplateUtil.GetPlanTemplate( "TestProject", "ContainerApp", "azd", @@ -122,7 +122,7 @@ public void GetPlanTemplate_AzdWithoutIacOptions_DefaultsToBicep() public void GetPlanTemplate_AksTarget_IncludesKubernetesSteps() { // Act - var result = DeploymentPlanTemplateUtilV2.GetPlanTemplate( + var result = DeploymentPlanTemplateUtil.GetPlanTemplate( "TestProject", "AKS", "AZD", @@ -138,7 +138,7 @@ public void GetPlanTemplate_AksTarget_IncludesKubernetesSteps() public void GetPlanTemplate_ContainerAppWithAzCli_IncludesDockerSteps() { // Act - var result = DeploymentPlanTemplateUtilV2.GetPlanTemplate( + var result = DeploymentPlanTemplateUtil.GetPlanTemplate( "TestProject", "ContainerApp", "AzCli", From 52853c59ef88190cac068aa0e9bb4f5330c2fd3c Mon Sep 17 00:00:00 2001 From: qianwens Date: Wed, 30 Jul 2025 16:29:58 +0800 Subject: [PATCH 13/56] add deploy command in md and fix analyze error --- docs/azmcp-commands.md | 29 ++++++++++ .../Services/Templates/TemplateService.cs | 4 +- .../Util/DeploymentPlanTemplateUtil.cs | 10 ++-- .../Services/Util/IaCRulesTemplateUtil.cs | 20 +++---- .../ToolLoading/CommandFactoryToolLoader.cs | 2 +- .../UnitTests/ArchitectureDiagramTests.cs | 4 +- .../DeploymentPlanTemplateUtilV2Tests.cs | 54 +++++++++---------- .../Extensions/CommandExtensionsTests.cs | 2 +- 8 files changed, 77 insertions(+), 48 deletions(-) diff --git a/docs/azmcp-commands.md b/docs/azmcp-commands.md index 7f9dafa1d..7818292cb 100644 --- a/docs/azmcp-commands.md +++ b/docs/azmcp-commands.md @@ -813,6 +813,35 @@ azmcp quota available-region-list --subscription \ [--cognitive-service-deployment-sku-name ] ``` +### Deploy +```bash +# Get a deployment plan for a specific project +azmcp deploy plan-get --workspace-folder \ + --project-name \ + --target-app-service \ + --provisioning-tool \ + [--azd-iac-options ] + +# Get the iac generation rules for the resource types +azmcp deploy iac-rules-get --deployment-tool \ + --iac-type \ + --resource-types + +# Get the application service log for a specific azd environment +azmcp deploy azd-app-log-get --workspace-folder \ + --azd-env-name \ + [--limit ] + +# Get the ci/cd pipeline guidance +azmcp deploy cicd-pipeline-guidance-get [--use-azd-pipeline-config ] \ + [--organization-name ] \ + [--repository-name ] \ + [--github-environment-name ] + +# Generate a mermaid architecture diagram for the application topology +azmcp deploy architecture-diagram-generate --raw-mcp-tool-input +``` + ## Response Format All responses follow a consistent JSON format: diff --git a/src/Areas/Deploy/Services/Templates/TemplateService.cs b/src/Areas/Deploy/Services/Templates/TemplateService.cs index b691cefb0..f93205d0b 100644 --- a/src/Areas/Deploy/Services/Templates/TemplateService.cs +++ b/src/Areas/Deploy/Services/Templates/TemplateService.cs @@ -61,13 +61,13 @@ public static string ProcessTemplate(string templateName, Dictionary replacements) { var result = new StringBuilder(templateContent); - + foreach (var (placeholder, value) in replacements) { var token = $"{{{{{placeholder}}}}}"; // {{placeholder}} result.Replace(token, value); } - + return result.ToString(); } diff --git a/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs b/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs index d64d97eec..b29bab43a 100644 --- a/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs +++ b/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs @@ -30,7 +30,7 @@ public static string GetPlanTemplate(string projectName, string targetAppService DeploymentPlanTemplateParameters parameters = CreateTemplateParameters(projectName, targetAppService, provisioningTool, azdIacOptions); var executionSteps = GenerateExecutionSteps(parameters); - + parameters.ExecutionSteps = executionSteps; return TemplateService.ProcessTemplate("Plan/deployment-plan-base", parameters.ToDictionary()); @@ -40,9 +40,9 @@ public static string GetPlanTemplate(string projectName, string targetAppService /// Creates template parameters from the provided inputs. /// private static DeploymentPlanTemplateParameters CreateTemplateParameters( - string projectName, - string targetAppService, - string provisioningTool, + string projectName, + string targetAppService, + string provisioningTool, string? azdIacOptions) { var azureComputeHost = GetAzureComputeHost(targetAppService); @@ -102,7 +102,7 @@ private static string GenerateExecutionSteps(DeploymentPlanTemplateParameters pa private static List GenerateAzdSteps(DeploymentPlanTemplateParameters parameters, bool isAks) { var steps = new List(); - + var deployTitle = isAks ? "" : " And Deploy the Application"; var checkLog = isAks ? "" : "6. Check the application log with tool `azd-app-log-get` to ensure the services are running."; diff --git a/src/Areas/Deploy/Services/Util/IaCRulesTemplateUtil.cs b/src/Areas/Deploy/Services/Util/IaCRulesTemplateUtil.cs index 8aae6ed05..c3b7e2286 100644 --- a/src/Areas/Deploy/Services/Util/IaCRulesTemplateUtil.cs +++ b/src/Areas/Deploy/Services/Util/IaCRulesTemplateUtil.cs @@ -32,7 +32,7 @@ public static string GetIaCRules(string deploymentTool, string iacType, string[] var iacTypeRules = GenerateIaCTypeRules(parameters); var resourceSpecificRules = GenerateResourceSpecificRules(parameters); var finalInstructions = GenerateFinalInstructions(parameters); - + parameters.DeploymentToolRules = deploymentToolRules; parameters.IacTypeRules = iacTypeRules; parameters.ResourceSpecificRules = resourceSpecificRules; @@ -47,8 +47,8 @@ public static string GetIaCRules(string deploymentTool, string iacType, string[] /// Creates template parameters from the provided inputs. /// private static IaCRulesTemplateParameters CreateTemplateParameters( - string deploymentTool, - string iacType, + string deploymentTool, + string iacType, string[] resourceTypes) { var parameters = new IaCRulesTemplateParameters @@ -71,14 +71,14 @@ private static IaCRulesTemplateParameters CreateTemplateParameters( private static void SetIaCTypeSpecificParameters(IaCRulesTemplateParameters parameters) { parameters.OutputFileName = parameters.IacType == IacType.Bicep ? "main.bicep" : "outputs.tf"; - parameters.RoleAssignmentResource = parameters.IacType == IacType.Bicep - ? "Microsoft.Authorization/roleAssignments" + parameters.RoleAssignmentResource = parameters.IacType == IacType.Bicep + ? "Microsoft.Authorization/roleAssignments" : "azurerm_role_assignment"; - parameters.ImageProperty = parameters.IacType == IacType.Bicep - ? "properties.template.containers.image" + parameters.ImageProperty = parameters.IacType == IacType.Bicep + ? "properties.template.containers.image" : "azurerm_container_app.template.container.image"; - parameters.DiagnosticSettingsResource = parameters.IacType == IacType.Bicep - ? "Microsoft.Insights/diagnosticSettings" + parameters.DiagnosticSettingsResource = parameters.IacType == IacType.Bicep + ? "Microsoft.Insights/diagnosticSettings" : "azurerm_monitor_diagnostic_setting"; // Set CORS configuration based on IaC type @@ -119,7 +119,7 @@ private static string GenerateDeploymentToolRules(IaCRulesTemplateParameters par { "OutputFileName", parameters.OutputFileName }, { "ContainerRegistryOutput", containerRegistryOutput } }; - + return TemplateService.ProcessTemplate("IaCRules/azd-rules", azdReplacements); } else if (parameters.DeploymentTool.Equals(DeploymentTool.AzCli, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs b/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs index 34bcdf390..5c45b1288 100644 --- a/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs +++ b/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs @@ -36,7 +36,7 @@ public sealed class CommandFactoryToolLoader( : commandFactory.GroupCommands(options.Value.Namespace); private readonly ILogger _logger = logger; - public const string RawMcpToolInputOptionName = "rawMcpToolInput"; + public const string RawMcpToolInputOptionName = "raw-mcp-tool-input"; /// /// Gets whether the tool loader operates in read-only mode. diff --git a/tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs b/tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs index 691104fea..7af384ccd 100644 --- a/tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs +++ b/tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs @@ -34,7 +34,7 @@ public ArchitectureDiagramTests() public async Task GenerateArchitectureDiagram_ShouldReturnNoServiceDetected() { var command = new GenerateArchitectureDiagramCommand(_logger); - var args = command.GetCommand().Parse(["--rawMcpToolInput", "{\"projectName\": \"test\",\"services\": []}"]); + var args = command.GetCommand().Parse(["--raw-mcp-tool-input", "{\"projectName\": \"test\",\"services\": []}"]); var context = new CommandContext(_serviceProvider); var response = await command.ExecuteAsync(context, args); Assert.NotNull(response); @@ -115,7 +115,7 @@ public async Task GenerateArchitectureDiagram_ShouldReturnEncryptedDiagramUrl() } }; - var args = command.GetCommand().Parse(["--rawMcpToolInput", JsonSerializer.Serialize(appTopology)]); + var args = command.GetCommand().Parse(["--raw-mcp-tool-input", JsonSerializer.Serialize(appTopology)]); var context = new CommandContext(_serviceProvider); var response = await command.ExecuteAsync(context, args); Assert.NotNull(response); diff --git a/tests/Areas/Deploy/UnitTests/DeploymentPlanTemplateUtilV2Tests.cs b/tests/Areas/Deploy/UnitTests/DeploymentPlanTemplateUtilV2Tests.cs index 5c7e17837..8a8ccc9a3 100644 --- a/tests/Areas/Deploy/UnitTests/DeploymentPlanTemplateUtilV2Tests.cs +++ b/tests/Areas/Deploy/UnitTests/DeploymentPlanTemplateUtilV2Tests.cs @@ -13,22 +13,22 @@ public sealed class DeploymentPlanTemplateUtilV2Tests [InlineData("", "WebApp", "AzCli", "")] [InlineData("MyApp", "AKS", "AZD", "terraform")] public void GetPlanTemplate_ValidInputs_ReturnsFormattedTemplate( - string projectName, - string targetAppService, - string provisioningTool, + string projectName, + string targetAppService, + string provisioningTool, string azdIacOptions) { // Act var result = DeploymentPlanTemplateUtil.GetPlanTemplate( - projectName, - targetAppService, - provisioningTool, + projectName, + targetAppService, + provisioningTool, azdIacOptions); // Assert Assert.NotNull(result); Assert.NotEmpty(result); - + // Should contain expected sections Assert.Contains("## **Goal**", result); Assert.Contains("## **Project Information**", result); @@ -39,7 +39,7 @@ public void GetPlanTemplate_ValidInputs_ReturnsFormattedTemplate( // Should not contain unprocessed placeholders for main content Assert.DoesNotContain("{{Title}}", result); Assert.DoesNotContain("{{ProvisioningTool}}", result); - + // Should contain appropriate provisioning tool if (provisioningTool.ToLowerInvariant() == "azd") { @@ -56,9 +56,9 @@ public void GetPlanTemplate_EmptyProjectName_UsesDefaultTitle() { // Act var result = DeploymentPlanTemplateUtil.GetPlanTemplate( - "", - "ContainerApp", - "AZD", + "", + "ContainerApp", + "AZD", "bicep"); // Assert @@ -74,9 +74,9 @@ public void GetPlanTemplate_WithProjectName_UsesProjectSpecificTitle() // Act var result = DeploymentPlanTemplateUtil.GetPlanTemplate( - projectName, - "ContainerApp", - "AZD", + projectName, + "ContainerApp", + "AZD", "bicep"); // Assert @@ -90,14 +90,14 @@ public void GetPlanTemplate_WithProjectName_UsesProjectSpecificTitle() [InlineData("aks", "Azure Kubernetes Service")] [InlineData("unknown", "Azure Container Apps")] // Default case public void GetPlanTemplate_DifferentTargetServices_MapsToCorrectAzureHost( - string targetAppService, + string targetAppService, string expectedAzureHost) { // Act var result = DeploymentPlanTemplateUtil.GetPlanTemplate( - "TestProject", - targetAppService, - "AZD", + "TestProject", + targetAppService, + "AZD", "bicep"); // Assert @@ -109,9 +109,9 @@ public void GetPlanTemplate_AzdWithoutIacOptions_DefaultsToBicep() { // Act var result = DeploymentPlanTemplateUtil.GetPlanTemplate( - "TestProject", - "ContainerApp", - "azd", + "TestProject", + "ContainerApp", + "azd", ""); // Assert @@ -123,9 +123,9 @@ public void GetPlanTemplate_AksTarget_IncludesKubernetesSteps() { // Act var result = DeploymentPlanTemplateUtil.GetPlanTemplate( - "TestProject", - "AKS", - "AZD", + "TestProject", + "AKS", + "AZD", "bicep"); // Assert @@ -139,9 +139,9 @@ public void GetPlanTemplate_ContainerAppWithAzCli_IncludesDockerSteps() { // Act var result = DeploymentPlanTemplateUtil.GetPlanTemplate( - "TestProject", - "ContainerApp", - "AzCli", + "TestProject", + "ContainerApp", + "AzCli", ""); // Assert diff --git a/tests/Commands/Extensions/CommandExtensionsTests.cs b/tests/Commands/Extensions/CommandExtensionsTests.cs index c4f95c10d..9174b8ca9 100644 --- a/tests/Commands/Extensions/CommandExtensionsTests.cs +++ b/tests/Commands/Extensions/CommandExtensionsTests.cs @@ -326,7 +326,7 @@ public void ParseFromRawMcpToolInput() { // Arrange var command = new Command("test"); - var scriptOption = new Option("--rawMcpToolInput") { IsRequired = true }; + var scriptOption = new Option("--raw-mcp-tool-input") { IsRequired = true }; command.AddOption(scriptOption); var arguments = new Dictionary From 8dda05b56b6d1c7c10eb5f3a2a29098ddcaeb5ef Mon Sep 17 00:00:00 2001 From: xfz11 <81600993+xfz11@users.noreply.github.com> Date: Wed, 30 Jul 2025 08:53:53 +0000 Subject: [PATCH 14/56] Some fix according to e2e test (#12) * fix prompt for test * format * update * fix * update * update * add rules --- src/Areas/Deploy/Commands/IaCRulesGetCommand.cs | 2 +- src/Areas/Deploy/Options/DeployOptionDefinitions.cs | 10 +++++----- src/Areas/Deploy/Services/Util/IaCRulesTemplateUtil.cs | 9 ++++++--- src/Areas/Deploy/Templates/IaCRules/azcli-rules.md | 5 ++++- .../Deploy/Templates/Plan/deployment-plan-base.md | 2 +- .../Areas/Deploy/UnitTests/IaCRulesGetCommandTests.cs | 7 +++---- tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs | 3 +-- 7 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/Areas/Deploy/Commands/IaCRulesGetCommand.cs b/src/Areas/Deploy/Commands/IaCRulesGetCommand.cs index 5a27f8239..843919a52 100644 --- a/src/Areas/Deploy/Commands/IaCRulesGetCommand.cs +++ b/src/Areas/Deploy/Commands/IaCRulesGetCommand.cs @@ -75,7 +75,7 @@ public override Task ExecuteAsync(CommandContext context, Parse } catch (Exception ex) { - _logger.LogError(ex, "An exception occurred listing accounts."); + _logger.LogError(ex, "An exception occurred while retrieving IaC rules."); HandleException(context, ex); } return Task.FromResult(context.Response); diff --git a/src/Areas/Deploy/Options/DeployOptionDefinitions.cs b/src/Areas/Deploy/Options/DeployOptionDefinitions.cs index 787bb4d0e..7d232b619 100644 --- a/src/Areas/Deploy/Options/DeployOptionDefinitions.cs +++ b/src/Areas/Deploy/Options/DeployOptionDefinitions.cs @@ -139,7 +139,7 @@ public static class PlanGet public static readonly Option AzdIacOptions = new( $"--{AzdIacOptionsName}", - "The Infrastructure as Code option for azd. Valid values: bicep, terraform." + "The Infrastructure as Code option for azd. Valid values: bicep, terraform. Leave empty if Deployment tool is AzCli." ) { IsRequired = false @@ -157,16 +157,16 @@ public static class IaCRules public static readonly Option IacType = new( "--iac-type", - "The Infrastructure as Code type. Valid values: bicep, terraform") + "The Infrastructure as Code type. Valid values: bicep, terraform. Leave empty if deployment-tool is AzCli.") { - IsRequired = true + IsRequired = false }; public static readonly Option ResourceTypes = new( "--resource-types", - "Comma-separated list of Azure resource types to generate rules for. Supported values: 'appservice' (App Service) and/or 'containerapp' (Container App) and/or 'function' (Function App). Other resources do not have special rules.") + "Specifies the Azure resource types to retrieve IaC rules for. It should be comma-separated. Supported values are: 'appservice', 'containerapp', 'function', 'aks'. If none of these services are used, this parameter can be left empty.") { - IsRequired = true, + IsRequired = false, AllowMultipleArgumentsPerToken = true }; } diff --git a/src/Areas/Deploy/Services/Util/IaCRulesTemplateUtil.cs b/src/Areas/Deploy/Services/Util/IaCRulesTemplateUtil.cs index c3b7e2286..3af087b5c 100644 --- a/src/Areas/Deploy/Services/Util/IaCRulesTemplateUtil.cs +++ b/src/Areas/Deploy/Services/Util/IaCRulesTemplateUtil.cs @@ -21,14 +21,17 @@ public static class IaCRulesTemplateUtil /// A formatted IaC rules string. public static string GetIaCRules(string deploymentTool, string iacType, string[] resourceTypes) { + var parameters = CreateTemplateParameters(deploymentTool, iacType, resourceTypes); + var deploymentToolRules = GenerateDeploymentToolRules(parameters); + if (deploymentTool.Equals(DeploymentTool.AzCli, StringComparison.OrdinalIgnoreCase)) + { + return TemplateService.LoadTemplate("IaCRules/azcli-rules"); + } // Default values for optional parameters if (string.IsNullOrWhiteSpace(iacType)) { iacType = "bicep"; } - - var parameters = CreateTemplateParameters(deploymentTool, iacType, resourceTypes); - var deploymentToolRules = GenerateDeploymentToolRules(parameters); var iacTypeRules = GenerateIaCTypeRules(parameters); var resourceSpecificRules = GenerateResourceSpecificRules(parameters); var finalInstructions = GenerateFinalInstructions(parameters); diff --git a/src/Areas/Deploy/Templates/IaCRules/azcli-rules.md b/src/Areas/Deploy/Templates/IaCRules/azcli-rules.md index 2a59cf271..2487d9fe0 100644 --- a/src/Areas/Deploy/Templates/IaCRules/azcli-rules.md +++ b/src/Areas/Deploy/Templates/IaCRules/azcli-rules.md @@ -1 +1,4 @@ -- No additional rules. +- If creating AzCli script, the script should stop if any command fails. Fix the error before proceeding. +- Azure resource Naming: All resources should be named like {resourcePrefix}{resourceToken}{instance}. Alphanumeric only! Don't use special characters! resourcePrefix is a prefix for the resource (ex. 'kv' for key vault) and <= 3 characters. resourceToken is a random string and = 5 characters. It should be used for all azure resources in a resource group. instance is a number that can be used when there are multiple resource with the same type. For example, resourceToken=abcde, then resource name: rgabcde(resource group), kvabvcde(keyvault), sqlabcde(sql), appabcde1(container app 1), appabcde2(container app 2). +- Kubernetes (K8s) YAML naming: only Lowercase letters (a-z), digits (0-9), hyphens (-) is allowed. Must start and end with a letter or digit. Less than 20 characters. + diff --git a/src/Areas/Deploy/Templates/Plan/deployment-plan-base.md b/src/Areas/Deploy/Templates/Plan/deployment-plan-base.md index bd2e4676c..dc7439645 100644 --- a/src/Areas/Deploy/Templates/Plan/deployment-plan-base.md +++ b/src/Areas/Deploy/Templates/Plan/deployment-plan-base.md @@ -1,7 +1,7 @@ # {{Title}} ## **Goal** -Based on the project to provide a plan to deploy the project to Azure using {{ProvisioningTool}}. +Based on the project to provide a plan to deploy the project to {{AzureComputeHost}} using {{ProvisioningTool}}. ## **Project Information** { diff --git a/tests/Areas/Deploy/UnitTests/IaCRulesGetCommandTests.cs b/tests/Areas/Deploy/UnitTests/IaCRulesGetCommandTests.cs index 8773dd040..24581631b 100644 --- a/tests/Areas/Deploy/UnitTests/IaCRulesGetCommandTests.cs +++ b/tests/Areas/Deploy/UnitTests/IaCRulesGetCommandTests.cs @@ -119,8 +119,8 @@ public async Task Should_get_infrastructure_rules_for_azcli_deployment_tool() // arrange var args = _parser.Parse([ "--deployment-tool", "AzCli", - "--iac-type", "bicep", - "--resource-types", "appservice" + "--iac-type", "", + "--resource-types", "aks" ]); // act @@ -130,8 +130,7 @@ public async Task Should_get_infrastructure_rules_for_azcli_deployment_tool() Assert.NotNull(result); Assert.Equal(200, result.Status); Assert.NotNull(result.Message); - Assert.Contains("Deployment Tool AzCli", result.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("No additional rules", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("If creating AzCli script, the script should stop if any command fails.", result.Message, StringComparison.OrdinalIgnoreCase); } [Fact] diff --git a/tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs b/tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs index 0f4bd6e51..5a345f256 100644 --- a/tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs +++ b/tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs @@ -85,9 +85,8 @@ public async Task Should_get_plan_for_kubernetes() "--workspace-folder", "C:/k8s-project", "--project-name", "k8s-app", "--target-app-service", "AKS", - "--provisioning-tool", "terraform" + "--provisioning-tool", "azcli" ]); - var context = new CommandContext(_serviceProvider); // act var result = await _command.ExecuteAsync(_context, args); From d8e05ff3bfdf84b361024ff3c8adb3ad95aabc6b Mon Sep 17 00:00:00 2001 From: qianwens Date: Thu, 31 Jul 2025 16:06:05 +0800 Subject: [PATCH 15/56] Refactor deploy"a to project --- AzureMcp.sln | 108 ++++++++++++++++++ Directory.Packages.props | 1 - .../src/AzureMcp.Deploy/AssemblyInfo.cs | 7 ++ .../AzureMcp.Deploy/AzureMcp.Deploy.csproj | 29 +++++ .../Commands/AzdAppLogGetCommand.cs | 16 +-- .../Commands/DeployJsonContext.cs | 6 +- .../GenerateArchitectureDiagramCommand.cs | 10 +- .../Commands/GenerateMermaidChart.cs | 4 +- .../Commands/IaCRulesGetCommand.cs | 18 ++- .../Commands/PipelineGenerateCommand.cs | 16 ++- .../Commands/PlanGetCommand.cs | 14 +-- .../src/AzureMcp.Deploy}/DeploySetup.cs | 16 +-- .../src/AzureMcp.Deploy/GlobalUsings.cs | 10 ++ .../src/AzureMcp.Deploy}/Models/Consts.cs | 2 +- .../AzureMcp.Deploy}/Models/EncodeMermaid.cs | 2 +- .../Models/IaCRulesParameters.cs | 2 +- .../AzureMcp.Deploy}/Models/MermaidData.cs | 2 +- .../DeploymentPlanTemplateParameters.cs | 2 +- .../Templates/IaCRulesTemplateParameters.cs | 2 +- .../AzureMcp.Deploy}/Options/AppTopology.cs | 4 +- .../Options/DeployAppLogOptions.cs | 4 +- .../Options/DeployOptionDefinitions.cs | 8 +- .../Options/InfraCodeRulesOptions.cs | 2 +- .../Options/PipelineGenerateOptions.cs | 4 +- .../Options/PlanGetOptions.cs | 2 +- .../Options/RawMcpToolInputOptions.cs | 8 +- .../Services/DeployService.cs | 4 +- .../Services/IDeployService.cs | 6 +- .../Services/Templates/TemplateService.cs | 4 +- .../Services/Util/AzdAppLogRetriever.cs | 0 .../Services/Util/AzdResourceLogService.cs | 0 .../Util/DeploymentPlanTemplateUtil.cs | 8 +- .../Services/Util/IaCRulesTemplateUtil.cs | 8 +- .../Services/Util/JsonElementHelper.cs | 0 .../Services/Util/PipelineGenerationUtil.cs | 4 +- .../Templates/IaCRules/appservice-rules.md | 0 .../Templates/IaCRules/azcli-rules.md | 2 +- .../Templates/IaCRules/azd-rules.md | 0 .../Templates/IaCRules/base-iac-rules.md | 0 .../Templates/IaCRules/bicep-rules.md | 0 .../Templates/IaCRules/containerapp-rules.md | 0 .../Templates/IaCRules/final-instructions.md | 0 .../Templates/IaCRules/functionapp-rules.md | 0 .../Templates/IaCRules/terraform-rules.md | 0 .../Templates/Plan/aks-steps.md | 0 .../Templates/Plan/azcli-steps.md | 0 .../Templates/Plan/azd-steps.md | 0 .../Templates/Plan/containerapp-steps.md | 0 .../Templates/Plan/deployment-plan-base.md | 0 .../Templates/Plan/summary-steps.md | 0 .../AzureMcp.Deploy.LiveTests.csproj | 17 +++ .../DeployCommandTests.cs | 13 +-- .../ArchitectureDiagramTests.cs | 10 +- .../AzdAppLogGetCommandTests.cs | 10 +- .../AzureMcp.Deploy.UnitTests.csproj | 17 +++ .../DeploymentPlanTemplateUtilV2Tests.cs | 4 +- .../IaCRulesGetCommandTests.cs | 8 +- .../PipelineGenerateCommandTests.cs | 8 +- .../PlanGetCommandTests.cs | 8 +- .../TemplateServiceTests.cs | 4 +- .../quota/src/AzureMcp.Quota/AssemblyInfo.cs | 7 ++ .../src/AzureMcp.Quota/AzureMcp.Quota.csproj | 33 ++++++ .../Commands/QuotaJsonContext.cs | 6 +- .../Commands/RegionCheckCommand.cs | 21 ++-- .../Commands/UsageCheckCommand.cs | 23 ++-- .../quota/src/AzureMcp.Quota/GlobalUsings.cs | 10 ++ .../Models/CognitiveServiceProperties.cs | 2 +- .../Options/QuotaOptionDefinitions.cs | 2 +- .../Options/RegionCheckOptions.cs | 4 +- .../Options/UsageCheckOptions.cs | 4 +- .../quota/src/AzureMcp.Quota}/QuotaSetup.cs | 11 +- .../AzureMcp.Quota}/Services/IQuotaService.cs | 4 +- .../AzureMcp.Quota}/Services/QuotaService.cs | 8 +- .../Services/Util/AzureRegionChecker.cs | 4 +- .../Services/Util/AzureUsageChecker.cs | 7 +- .../Services/Util/JsonElementHelper.cs | 18 +++ .../Usage/CognitiveServicesUsageChecker.cs | 5 +- .../Util/Usage/ComputeUsageChecker.cs | 5 +- .../Util/Usage/ContainerAppUsageChecker.cs | 5 +- .../Usage/ContainerInstanceUsageChecker.cs | 5 +- .../Util/Usage/HDInsightUsageChecker.cs | 5 +- .../Util/Usage/MachineLearningUsageChecker.cs | 5 +- .../Util/Usage/NetworkUsageChecker.cs | 5 +- .../Util/Usage/PostgreSQLUsageChecker.cs | 6 +- .../Services/Util/Usage/SearchUsageChecker.cs | 5 +- .../Util/Usage/StorageUsageChecker.cs | 5 +- .../AzureMcp.Quota.LiveTests.csproj | 17 +++ .../QuotaCommandTests.cs | 0 .../AzureMcp.Quota.UnitTests.csproj | 17 +++ .../RegionCheckCommandTests.cs | 6 +- .../UsageCheckCommandTests.cs | 8 +- core/src/AzureMcp.Cli/Program.cs | 2 + .../ToolLoading/CommandFactoryToolLoader.cs | 10 +- .../CommandFactoryToolLoaderTests.cs | 6 +- 94 files changed, 509 insertions(+), 206 deletions(-) create mode 100644 areas/deploy/src/AzureMcp.Deploy/AssemblyInfo.cs create mode 100644 areas/deploy/src/AzureMcp.Deploy/AzureMcp.Deploy.csproj rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Commands/AzdAppLogGetCommand.cs (90%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Commands/DeployJsonContext.cs (82%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Commands/GenerateArchitectureDiagramCommand.cs (96%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Commands/GenerateMermaidChart.cs (99%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Commands/IaCRulesGetCommand.cs (90%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Commands/PipelineGenerateCommand.cs (90%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Commands/PlanGetCommand.cs (92%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/DeploySetup.cs (79%) create mode 100644 areas/deploy/src/AzureMcp.Deploy/GlobalUsings.cs rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Models/Consts.cs (95%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Models/EncodeMermaid.cs (98%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Models/IaCRulesParameters.cs (92%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Models/MermaidData.cs (91%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Models/Templates/DeploymentPlanTemplateParameters.cs (97%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Models/Templates/IaCRulesTemplateParameters.cs (98%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Options/AppTopology.cs (96%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Options/DeployAppLogOptions.cs (87%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Options/DeployOptionDefinitions.cs (98%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Options/InfraCodeRulesOptions.cs (88%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Options/PipelineGenerateOptions.cs (89%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Options/PlanGetOptions.cs (94%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Options/RawMcpToolInputOptions.cs (64%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Services/DeployService.cs (92%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Services/IDeployService.cs (72%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Services/Templates/TemplateService.cs (95%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Services/Util/AzdAppLogRetriever.cs (100%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Services/Util/AzdResourceLogService.cs (100%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Services/Util/DeploymentPlanTemplateUtil.cs (97%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Services/Util/IaCRulesTemplateUtil.cs (98%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Services/Util/JsonElementHelper.cs (100%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Services/Util/PipelineGenerationUtil.cs (98%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Templates/IaCRules/appservice-rules.md (100%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Templates/IaCRules/azcli-rules.md (80%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Templates/IaCRules/azd-rules.md (100%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Templates/IaCRules/base-iac-rules.md (100%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Templates/IaCRules/bicep-rules.md (100%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Templates/IaCRules/containerapp-rules.md (100%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Templates/IaCRules/final-instructions.md (100%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Templates/IaCRules/functionapp-rules.md (100%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Templates/IaCRules/terraform-rules.md (100%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Templates/Plan/aks-steps.md (100%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Templates/Plan/azcli-steps.md (100%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Templates/Plan/azd-steps.md (100%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Templates/Plan/containerapp-steps.md (100%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Templates/Plan/deployment-plan-base.md (100%) rename {src/Areas/Deploy => areas/deploy/src/AzureMcp.Deploy}/Templates/Plan/summary-steps.md (100%) create mode 100644 areas/deploy/tests/AzureMcp.Deploy.LiveTests/AzureMcp.Deploy.LiveTests.csproj rename {tests/Areas/Deploy/LiveTests => areas/deploy/tests/AzureMcp.Deploy.LiveTests}/DeployCommandTests.cs (94%) rename {tests/Areas/Deploy/UnitTests => areas/deploy/tests/AzureMcp.Deploy.UnitTests}/ArchitectureDiagramTests.cs (96%) rename {tests/Areas/Deploy/UnitTests => areas/deploy/tests/AzureMcp.Deploy.UnitTests}/AzdAppLogGetCommandTests.cs (97%) create mode 100644 areas/deploy/tests/AzureMcp.Deploy.UnitTests/AzureMcp.Deploy.UnitTests.csproj rename {tests/Areas/Deploy/UnitTests => areas/deploy/tests/AzureMcp.Deploy.UnitTests}/DeploymentPlanTemplateUtilV2Tests.cs (97%) rename {tests/Areas/Deploy/UnitTests => areas/deploy/tests/AzureMcp.Deploy.UnitTests}/IaCRulesGetCommandTests.cs (97%) rename {tests/Areas/Deploy/UnitTests => areas/deploy/tests/AzureMcp.Deploy.UnitTests}/PipelineGenerateCommandTests.cs (97%) rename {tests/Areas/Deploy/UnitTests => areas/deploy/tests/AzureMcp.Deploy.UnitTests}/PlanGetCommandTests.cs (96%) rename {tests/Areas/Deploy/UnitTests => areas/deploy/tests/AzureMcp.Deploy.UnitTests}/TemplateServiceTests.cs (95%) create mode 100644 areas/quota/src/AzureMcp.Quota/AssemblyInfo.cs create mode 100644 areas/quota/src/AzureMcp.Quota/AzureMcp.Quota.csproj rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Commands/QuotaJsonContext.cs (84%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Commands/RegionCheckCommand.cs (90%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Commands/UsageCheckCommand.cs (85%) create mode 100644 areas/quota/src/AzureMcp.Quota/GlobalUsings.cs rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Models/CognitiveServiceProperties.cs (88%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Options/QuotaOptionDefinitions.cs (98%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Options/RegionCheckOptions.cs (90%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Options/UsageCheckOptions.cs (85%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/QuotaSetup.cs (81%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Services/IQuotaService.cs (87%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Services/QuotaService.cs (93%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Services/Util/AzureRegionChecker.cs (99%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Services/Util/AzureUsageChecker.cs (97%) create mode 100644 areas/quota/src/AzureMcp.Quota/Services/Util/JsonElementHelper.cs rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Services/Util/Usage/CognitiveServicesUsageChecker.cs (91%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Services/Util/Usage/ComputeUsageChecker.cs (91%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Services/Util/Usage/ContainerAppUsageChecker.cs (90%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Services/Util/Usage/ContainerInstanceUsageChecker.cs (91%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Services/Util/Usage/HDInsightUsageChecker.cs (91%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Services/Util/Usage/MachineLearningUsageChecker.cs (90%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Services/Util/Usage/NetworkUsageChecker.cs (90%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Services/Util/Usage/PostgreSQLUsageChecker.cs (94%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Services/Util/Usage/SearchUsageChecker.cs (90%) rename {src/Areas/Quota => areas/quota/src/AzureMcp.Quota}/Services/Util/Usage/StorageUsageChecker.cs (90%) create mode 100644 areas/quota/tests/AzureMcp.Quota.LiveTests/AzureMcp.Quota.LiveTests.csproj rename {tests/Areas/Quota/LiveTests => areas/quota/tests/AzureMcp.Quota.LiveTests}/QuotaCommandTests.cs (100%) create mode 100644 areas/quota/tests/AzureMcp.Quota.UnitTests/AzureMcp.Quota.UnitTests.csproj rename {tests/Areas/Quota/UnitTests => areas/quota/tests/AzureMcp.Quota.UnitTests}/RegionCheckCommandTests.cs (99%) rename {tests/Areas/Quota/UnitTests => areas/quota/tests/AzureMcp.Quota.UnitTests}/UsageCheckCommandTests.cs (98%) diff --git a/AzureMcp.sln b/AzureMcp.sln index 1b86911f1..753c49ea7 100644 --- a/AzureMcp.sln +++ b/AzureMcp.sln @@ -285,6 +285,30 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.Tests", "core\test EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.AppConfig.UnitTests", "areas\appconfig\tests\AzureMcp.AppConfig.UnitTests\AzureMcp.AppConfig.UnitTests.csproj", "{A3ADC1CC-6020-7233-DCFA-106CA917B0CD}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{546CED3D-7F74-09C6-3DFD-7EDC477A0556}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{311C78D5-1A27-A98E-17B9-D29F6B7DECD5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.Deploy", "areas\deploy\src\AzureMcp.Deploy\AzureMcp.Deploy.csproj", "{EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{C69FCF64-5DB8-4F7B-E427-FA8659F1F756}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.Deploy.LiveTests", "areas\deploy\tests\AzureMcp.Deploy.LiveTests\AzureMcp.Deploy.LiveTests.csproj", "{FDB76269-A532-42C5-A644-D31EDC3044FE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.Deploy.UnitTests", "areas\deploy\tests\AzureMcp.Deploy.UnitTests\AzureMcp.Deploy.UnitTests.csproj", "{3B1E3954-A8DB-4F39-9857-93FAF1C05376}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "quota", "quota", "{39FCE3A1-0566-13EE-296F-5B62330AD7F0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9337CB4E-9EBA-5799-A82A-3624631C64B6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.Quota", "areas\quota\src\AzureMcp.Quota\AzureMcp.Quota.csproj", "{DADE2EBB-412F-430D-A551-47D3E3CF77F6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{D6BC8A0D-6104-FA8D-53A6-10E79ED132DD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.Quota.LiveTests", "areas\quota\tests\AzureMcp.Quota.LiveTests\AzureMcp.Quota.LiveTests.csproj", "{1DC07369-9133-4B02-B2DD-A7E277163CB4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.Quota.UnitTests", "areas\quota\tests\AzureMcp.Quota.UnitTests\AzureMcp.Quota.UnitTests.csproj", "{0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1111,6 +1135,78 @@ Global {A3ADC1CC-6020-7233-DCFA-106CA917B0CD}.Release|x64.Build.0 = Release|Any CPU {A3ADC1CC-6020-7233-DCFA-106CA917B0CD}.Release|x86.ActiveCfg = Release|Any CPU {A3ADC1CC-6020-7233-DCFA-106CA917B0CD}.Release|x86.Build.0 = Release|Any CPU + {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Debug|x64.ActiveCfg = Debug|Any CPU + {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Debug|x64.Build.0 = Debug|Any CPU + {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Debug|x86.ActiveCfg = Debug|Any CPU + {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Debug|x86.Build.0 = Debug|Any CPU + {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Release|Any CPU.Build.0 = Release|Any CPU + {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Release|x64.ActiveCfg = Release|Any CPU + {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Release|x64.Build.0 = Release|Any CPU + {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Release|x86.ActiveCfg = Release|Any CPU + {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Release|x86.Build.0 = Release|Any CPU + {FDB76269-A532-42C5-A644-D31EDC3044FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FDB76269-A532-42C5-A644-D31EDC3044FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FDB76269-A532-42C5-A644-D31EDC3044FE}.Debug|x64.ActiveCfg = Debug|Any CPU + {FDB76269-A532-42C5-A644-D31EDC3044FE}.Debug|x64.Build.0 = Debug|Any CPU + {FDB76269-A532-42C5-A644-D31EDC3044FE}.Debug|x86.ActiveCfg = Debug|Any CPU + {FDB76269-A532-42C5-A644-D31EDC3044FE}.Debug|x86.Build.0 = Debug|Any CPU + {FDB76269-A532-42C5-A644-D31EDC3044FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FDB76269-A532-42C5-A644-D31EDC3044FE}.Release|Any CPU.Build.0 = Release|Any CPU + {FDB76269-A532-42C5-A644-D31EDC3044FE}.Release|x64.ActiveCfg = Release|Any CPU + {FDB76269-A532-42C5-A644-D31EDC3044FE}.Release|x64.Build.0 = Release|Any CPU + {FDB76269-A532-42C5-A644-D31EDC3044FE}.Release|x86.ActiveCfg = Release|Any CPU + {FDB76269-A532-42C5-A644-D31EDC3044FE}.Release|x86.Build.0 = Release|Any CPU + {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Debug|x64.ActiveCfg = Debug|Any CPU + {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Debug|x64.Build.0 = Debug|Any CPU + {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Debug|x86.ActiveCfg = Debug|Any CPU + {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Debug|x86.Build.0 = Debug|Any CPU + {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Release|Any CPU.Build.0 = Release|Any CPU + {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Release|x64.ActiveCfg = Release|Any CPU + {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Release|x64.Build.0 = Release|Any CPU + {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Release|x86.ActiveCfg = Release|Any CPU + {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Release|x86.Build.0 = Release|Any CPU + {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Debug|x64.ActiveCfg = Debug|Any CPU + {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Debug|x64.Build.0 = Debug|Any CPU + {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Debug|x86.ActiveCfg = Debug|Any CPU + {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Debug|x86.Build.0 = Debug|Any CPU + {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Release|Any CPU.Build.0 = Release|Any CPU + {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Release|x64.ActiveCfg = Release|Any CPU + {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Release|x64.Build.0 = Release|Any CPU + {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Release|x86.ActiveCfg = Release|Any CPU + {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Release|x86.Build.0 = Release|Any CPU + {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Debug|x64.ActiveCfg = Debug|Any CPU + {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Debug|x64.Build.0 = Debug|Any CPU + {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Debug|x86.ActiveCfg = Debug|Any CPU + {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Debug|x86.Build.0 = Debug|Any CPU + {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Release|Any CPU.Build.0 = Release|Any CPU + {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Release|x64.ActiveCfg = Release|Any CPU + {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Release|x64.Build.0 = Release|Any CPU + {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Release|x86.ActiveCfg = Release|Any CPU + {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Release|x86.Build.0 = Release|Any CPU + {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Debug|x64.ActiveCfg = Debug|Any CPU + {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Debug|x64.Build.0 = Debug|Any CPU + {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Debug|x86.ActiveCfg = Debug|Any CPU + {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Debug|x86.Build.0 = Debug|Any CPU + {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Release|Any CPU.Build.0 = Release|Any CPU + {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Release|x64.ActiveCfg = Release|Any CPU + {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Release|x64.Build.0 = Release|Any CPU + {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Release|x86.ActiveCfg = Release|Any CPU + {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1255,5 +1351,17 @@ Global {1AE3FC50-8E8C-4637-AAB1-A871D5FB4535} = {8783C0BC-EE27-8E0C-7452-5882FB8E96CA} {527FE0F6-40AE-4E71-A483-0F0A2368F2A7} = {8783C0BC-EE27-8E0C-7452-5882FB8E96CA} {A3ADC1CC-6020-7233-DCFA-106CA917B0CD} = {7ECA6DB2-F8EF-407B-F2FD-DEF81B86CC73} + {546CED3D-7F74-09C6-3DFD-7EDC477A0556} = {87783708-79E3-AD60-C783-1D52BE7DE4BB} + {311C78D5-1A27-A98E-17B9-D29F6B7DECD5} = {546CED3D-7F74-09C6-3DFD-7EDC477A0556} + {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF} = {311C78D5-1A27-A98E-17B9-D29F6B7DECD5} + {C69FCF64-5DB8-4F7B-E427-FA8659F1F756} = {546CED3D-7F74-09C6-3DFD-7EDC477A0556} + {FDB76269-A532-42C5-A644-D31EDC3044FE} = {C69FCF64-5DB8-4F7B-E427-FA8659F1F756} + {3B1E3954-A8DB-4F39-9857-93FAF1C05376} = {C69FCF64-5DB8-4F7B-E427-FA8659F1F756} + {39FCE3A1-0566-13EE-296F-5B62330AD7F0} = {87783708-79E3-AD60-C783-1D52BE7DE4BB} + {9337CB4E-9EBA-5799-A82A-3624631C64B6} = {39FCE3A1-0566-13EE-296F-5B62330AD7F0} + {DADE2EBB-412F-430D-A551-47D3E3CF77F6} = {9337CB4E-9EBA-5799-A82A-3624631C64B6} + {D6BC8A0D-6104-FA8D-53A6-10E79ED132DD} = {39FCE3A1-0566-13EE-296F-5B62330AD7F0} + {1DC07369-9133-4B02-B2DD-A7E277163CB4} = {D6BC8A0D-6104-FA8D-53A6-10E79ED132DD} + {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6} = {D6BC8A0D-6104-FA8D-53A6-10E79ED132DD} EndGlobalSection EndGlobal diff --git a/Directory.Packages.props b/Directory.Packages.props index 12e7e0b29..659cf79e7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -40,7 +40,6 @@ - diff --git a/areas/deploy/src/AzureMcp.Deploy/AssemblyInfo.cs b/areas/deploy/src/AzureMcp.Deploy/AssemblyInfo.cs new file mode 100644 index 000000000..8eb69fad7 --- /dev/null +++ b/areas/deploy/src/AzureMcp.Deploy/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("AzureMcp.AppConfig.UnitTests")] +[assembly: InternalsVisibleTo("AzureMcp.AppConfig.LiveTests")] diff --git a/areas/deploy/src/AzureMcp.Deploy/AzureMcp.Deploy.csproj b/areas/deploy/src/AzureMcp.Deploy/AzureMcp.Deploy.csproj new file mode 100644 index 000000000..1e01ba74b --- /dev/null +++ b/areas/deploy/src/AzureMcp.Deploy/AzureMcp.Deploy.csproj @@ -0,0 +1,29 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Areas/Deploy/Commands/AzdAppLogGetCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/AzdAppLogGetCommand.cs similarity index 90% rename from src/Areas/Deploy/Commands/AzdAppLogGetCommand.cs rename to areas/deploy/src/AzureMcp.Deploy/Commands/AzdAppLogGetCommand.cs index 8cab24e10..3d5d114be 100644 --- a/src/Areas/Deploy/Commands/AzdAppLogGetCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/AzdAppLogGetCommand.cs @@ -1,13 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using AzureMcp.Areas.Deploy.Options; -using AzureMcp.Areas.Deploy.Services; -using AzureMcp.Commands.Subscription; -using AzureMcp.Services.Telemetry; +using AzureMcp.Core.Commands; +using AzureMcp.Core.Commands.Subscription; +using AzureMcp.Core.Services.Telemetry; +using AzureMcp.Deploy.Options; +using AzureMcp.Deploy.Services; using Microsoft.Extensions.Logging; -namespace AzureMcp.Areas.Deploy.Commands; +namespace AzureMcp.Deploy.Commands; public sealed class AzdAppLogGetCommand(ILogger logger) : SubscriptionCommand() { @@ -19,14 +20,14 @@ public sealed class AzdAppLogGetCommand(ILogger logger) : S private readonly Option _limitOption = DeployOptionDefinitions.AzdAppLogOptions.Limit; public override string Name => "azd-app-log-get"; + public override string Title => CommandTitle; + public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true }; public override string Description => """ This tool helps fetch logs from log analytics workspace for Container Apps, App Services, function apps that were deployed through azd. Invoke this tool directly after a successful `azd up` or when user prompts to check the app's status or provide errors in the deployed apps. """; - public override string Title => CommandTitle; - protected override void RegisterOptions(Command command) { base.RegisterOptions(command); @@ -44,7 +45,6 @@ protected override AzdAppLogOptions BindOptions(ParseResult parseResult) return options; } - [McpServerTool(Destructive = false, ReadOnly = true, Title = CommandTitle)] public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) { var options = BindOptions(parseResult); diff --git a/src/Areas/Deploy/Commands/DeployJsonContext.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/DeployJsonContext.cs similarity index 82% rename from src/Areas/Deploy/Commands/DeployJsonContext.cs rename to areas/deploy/src/AzureMcp.Deploy/Commands/DeployJsonContext.cs index 7061f6bcb..33f0322e1 100644 --- a/src/Areas/Deploy/Commands/DeployJsonContext.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/DeployJsonContext.cs @@ -2,10 +2,10 @@ // Licensed under the MIT License. using System.Text.Json.Serialization; -using AzureMcp.Areas.Deploy.Models; -using AzureMcp.Areas.Deploy.Options; +using AzureMcp.Deploy.Models; +using AzureMcp.Deploy.Options; -namespace AzureMcp.Areas.Deploy.Commands; +namespace AzureMcp.Deploy.Commands; [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, diff --git a/src/Areas/Deploy/Commands/GenerateArchitectureDiagramCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateArchitectureDiagramCommand.cs similarity index 96% rename from src/Areas/Deploy/Commands/GenerateArchitectureDiagramCommand.cs rename to areas/deploy/src/AzureMcp.Deploy/Commands/GenerateArchitectureDiagramCommand.cs index 7a01dc711..5a40089cb 100644 --- a/src/Areas/Deploy/Commands/GenerateArchitectureDiagramCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateArchitectureDiagramCommand.cs @@ -4,12 +4,12 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; -using AzureMcp.Areas.Deploy.Options; -using AzureMcp.Commands; -using AzureMcp.Helpers; +using AzureMcp.Core.Commands; +using AzureMcp.Core.Helpers; +using AzureMcp.Deploy.Options; using Microsoft.Extensions.Logging; -namespace AzureMcp.Areas.Deploy.Commands; +namespace AzureMcp.Deploy.Commands; public sealed class GenerateArchitectureDiagramCommand(ILogger logger) : BaseCommand() { @@ -27,6 +27,7 @@ public sealed class GenerateArchitectureDiagramCommand(ILogger "Generate Architecture Diagram"; + public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true }; protected override void RegisterOptions(Command command) { @@ -41,7 +42,6 @@ private RawMcpToolInputOptions BindOptions(ParseResult parseResult) return options; } - [McpServerTool(Destructive = false, ReadOnly = true, Title = CommandTitle)] public override Task ExecuteAsync(CommandContext context, ParseResult parseResult) { try diff --git a/src/Areas/Deploy/Commands/GenerateMermaidChart.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateMermaidChart.cs similarity index 99% rename from src/Areas/Deploy/Commands/GenerateMermaidChart.cs rename to areas/deploy/src/AzureMcp.Deploy/Commands/GenerateMermaidChart.cs index b76f6c8e6..9c9960b18 100644 --- a/src/Areas/Deploy/Commands/GenerateMermaidChart.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateMermaidChart.cs @@ -3,10 +3,10 @@ using System.Collections.Immutable; using System.Text; -using AzureMcp.Areas.Deploy.Options; +using AzureMcp.Deploy.Options; using Microsoft.Extensions.ObjectPool; -namespace AzureMcp.Areas.Deploy.Commands; +namespace AzureMcp.Deploy.Commands; public static class GenerateMermaidChart { diff --git a/src/Areas/Deploy/Commands/IaCRulesGetCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/IaCRulesGetCommand.cs similarity index 90% rename from src/Areas/Deploy/Commands/IaCRulesGetCommand.cs rename to areas/deploy/src/AzureMcp.Deploy/Commands/IaCRulesGetCommand.cs index 843919a52..794821127 100644 --- a/src/Areas/Deploy/Commands/IaCRulesGetCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/IaCRulesGetCommand.cs @@ -2,13 +2,13 @@ // Licensed under the MIT License. using System.Diagnostics.CodeAnalysis; -using AzureMcp.Areas.Deploy.Models; -using AzureMcp.Areas.Deploy.Options; -using AzureMcp.Areas.Deploy.Services.Util; -using AzureMcp.Commands; +using AzureMcp.Core.Commands; +using AzureMcp.Deploy.Models; +using AzureMcp.Deploy.Options; +using AzureMcp.Deploy.Services.Util; using Microsoft.Extensions.Logging; -namespace AzureMcp.Areas.Deploy.Commands.InfraCodeRules; +namespace AzureMcp.Deploy.Commands.InfraCodeRules; public sealed class IaCRulesGetCommand(ILogger logger) : BaseCommand() @@ -21,14 +21,14 @@ public sealed class IaCRulesGetCommand(ILogger logger) private readonly Option _resourceTypesOption = DeployOptionDefinitions.IaCRules.ResourceTypes; public override string Name => "iac-rules-get"; + public override string Title => CommandTitle; + public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true }; public override string Description => """ This tool offers guidelines for creating Bicep/Terraform files to deploy applications on Azure. The guidelines outline rules to improve the quality of Infrastructure as Code files, ensuring they are compatible with the azd tool and adhere to best practices. """; - public override string Title => CommandTitle; - protected override void RegisterOptions(Command command) { base.RegisterOptions(command); @@ -47,10 +47,6 @@ private InfraCodeRulesOptions BindOptions(ParseResult parseResult) return options; } - [McpServerTool( - Destructive = false, - ReadOnly = true, - Title = CommandTitle)] public override Task ExecuteAsync(CommandContext context, ParseResult parseResult) { var options = BindOptions(parseResult); diff --git a/src/Areas/Deploy/Commands/PipelineGenerateCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/PipelineGenerateCommand.cs similarity index 90% rename from src/Areas/Deploy/Commands/PipelineGenerateCommand.cs rename to areas/deploy/src/AzureMcp.Deploy/Commands/PipelineGenerateCommand.cs index 69365d326..55e43e3c1 100644 --- a/src/Areas/Deploy/Commands/PipelineGenerateCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/PipelineGenerateCommand.cs @@ -1,13 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using AzureMcp.Areas.Deploy.Options; -using AzureMcp.Areas.Deploy.Services.Util; -using AzureMcp.Commands.Subscription; -using AzureMcp.Services.Telemetry; +using AzureMcp.Core.Commands; +using AzureMcp.Core.Commands.Subscription; +using AzureMcp.Core.Services.Telemetry; +using AzureMcp.Deploy.Options; +using AzureMcp.Deploy.Services.Util; using Microsoft.Extensions.Logging; -namespace AzureMcp.Areas.Deploy.Commands; +namespace AzureMcp.Deploy.Commands; public sealed class PipelineGenerateCommand(ILogger logger) : SubscriptionCommand() @@ -28,6 +29,7 @@ public sealed class PipelineGenerateCommand(ILogger log """; public override string Title => CommandTitle; + public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true }; protected override void RegisterOptions(Command command) { @@ -48,10 +50,6 @@ protected override PipelineGenerateOptions BindOptions(ParseResult parseResult) return options; } - [McpServerTool( - Destructive = false, - ReadOnly = false, - Title = CommandTitle)] public override Task ExecuteAsync(CommandContext context, ParseResult parseResult) { var options = BindOptions(parseResult); diff --git a/src/Areas/Deploy/Commands/PlanGetCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/PlanGetCommand.cs similarity index 92% rename from src/Areas/Deploy/Commands/PlanGetCommand.cs rename to areas/deploy/src/AzureMcp.Deploy/Commands/PlanGetCommand.cs index 00c09261c..152675ca2 100644 --- a/src/Areas/Deploy/Commands/PlanGetCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/PlanGetCommand.cs @@ -2,13 +2,12 @@ // Licensed under the MIT License. using System.Diagnostics.CodeAnalysis; - -using AzureMcp.Areas.Deploy.Options; -using AzureMcp.Areas.Deploy.Services.Util; -using AzureMcp.Commands; +using AzureMcp.Core.Commands; +using AzureMcp.Deploy.Options; +using AzureMcp.Deploy.Services.Util; using Microsoft.Extensions.Logging; -namespace AzureMcp.Areas.Deploy.Commands.Plan; +namespace AzureMcp.Deploy.Commands.Plan; public sealed class PlanGetCommand(ILogger logger) : BaseCommand() @@ -30,6 +29,7 @@ public sealed class PlanGetCommand(ILogger logger) """; public override string Title => CommandTitle; + public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true }; protected override void RegisterOptions(Command command) { @@ -53,10 +53,6 @@ private PlanGetOptions BindOptions(ParseResult parseResult) }; } - [McpServerTool( - Destructive = false, - ReadOnly = true, - Title = CommandTitle)] public override Task ExecuteAsync(CommandContext context, ParseResult parseResult) { var options = BindOptions(parseResult); diff --git a/src/Areas/Deploy/DeploySetup.cs b/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs similarity index 79% rename from src/Areas/Deploy/DeploySetup.cs rename to areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs index 71e0e77c1..cf524e7e4 100644 --- a/src/Areas/Deploy/DeploySetup.cs +++ b/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs @@ -1,18 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using AzureMcp.Areas.Deploy.Commands; -using AzureMcp.Areas.Deploy.Commands.InfraCodeRules; -using AzureMcp.Areas.Deploy.Commands.Plan; -using AzureMcp.Areas.Deploy.Services; -using AzureMcp.Areas.Extension.Commands; -using AzureMcp.Commands; +using AzureMcp.Core.Areas; +using AzureMcp.Core.Commands; +using AzureMcp.Deploy.Commands; +using AzureMcp.Deploy.Commands.InfraCodeRules; +using AzureMcp.Deploy.Commands.Plan; +using AzureMcp.Deploy.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace AzureMcp.Areas.Deploy; +namespace AzureMcp.Deploy; -internal sealed class DeploySetup : IAreaSetup +public sealed class DeploySetup : IAreaSetup { public void ConfigureServices(IServiceCollection services) { diff --git a/areas/deploy/src/AzureMcp.Deploy/GlobalUsings.cs b/areas/deploy/src/AzureMcp.Deploy/GlobalUsings.cs new file mode 100644 index 000000000..85a476736 --- /dev/null +++ b/areas/deploy/src/AzureMcp.Deploy/GlobalUsings.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +global using System.CommandLine; +global using System.CommandLine.Parsing; +global using System.Text.Json; +global using AzureMcp.Core.Extensions; +global using AzureMcp.Core.Models; +global using AzureMcp.Core.Models.Command; +global using ModelContextProtocol.Server; diff --git a/src/Areas/Deploy/Models/Consts.cs b/areas/deploy/src/AzureMcp.Deploy/Models/Consts.cs similarity index 95% rename from src/Areas/Deploy/Models/Consts.cs rename to areas/deploy/src/AzureMcp.Deploy/Models/Consts.cs index cd2c8857a..c70953e76 100644 --- a/src/Areas/Deploy/Models/Consts.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Models/Consts.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace AzureMcp.Areas.Deploy.Commands; +namespace AzureMcp.Deploy.Commands; public static class AzureServiceConstants { diff --git a/src/Areas/Deploy/Models/EncodeMermaid.cs b/areas/deploy/src/AzureMcp.Deploy/Models/EncodeMermaid.cs similarity index 98% rename from src/Areas/Deploy/Models/EncodeMermaid.cs rename to areas/deploy/src/AzureMcp.Deploy/Models/EncodeMermaid.cs index 371d94656..ee57c1576 100644 --- a/src/Areas/Deploy/Models/EncodeMermaid.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Models/EncodeMermaid.cs @@ -4,7 +4,7 @@ using System.IO.Compression; using System.Text; -namespace AzureMcp.Areas.Deploy.Commands; +namespace AzureMcp.Deploy.Commands; /// /// Utility class to encode and decode Mermaid charts. diff --git a/src/Areas/Deploy/Models/IaCRulesParameters.cs b/areas/deploy/src/AzureMcp.Deploy/Models/IaCRulesParameters.cs similarity index 92% rename from src/Areas/Deploy/Models/IaCRulesParameters.cs rename to areas/deploy/src/AzureMcp.Deploy/Models/IaCRulesParameters.cs index c607b039a..56230b967 100644 --- a/src/Areas/Deploy/Models/IaCRulesParameters.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Models/IaCRulesParameters.cs @@ -1,6 +1,6 @@ using System.Text.Json.Nodes; -namespace AzureMcp.Areas.Deploy.Models; +namespace AzureMcp.Deploy.Models; public static class DeploymentTool diff --git a/src/Areas/Deploy/Models/MermaidData.cs b/areas/deploy/src/AzureMcp.Deploy/Models/MermaidData.cs similarity index 91% rename from src/Areas/Deploy/Models/MermaidData.cs rename to areas/deploy/src/AzureMcp.Deploy/Models/MermaidData.cs index 9b4c0a2ae..27661774e 100644 --- a/src/Areas/Deploy/Models/MermaidData.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Models/MermaidData.cs @@ -3,7 +3,7 @@ using System.Text.Json.Serialization; -namespace AzureMcp.Areas.Deploy.Commands; +namespace AzureMcp.Deploy.Commands; public sealed class MermaidData { diff --git a/src/Areas/Deploy/Models/Templates/DeploymentPlanTemplateParameters.cs b/areas/deploy/src/AzureMcp.Deploy/Models/Templates/DeploymentPlanTemplateParameters.cs similarity index 97% rename from src/Areas/Deploy/Models/Templates/DeploymentPlanTemplateParameters.cs rename to areas/deploy/src/AzureMcp.Deploy/Models/Templates/DeploymentPlanTemplateParameters.cs index 9588095bd..aa9f9a96f 100644 --- a/src/Areas/Deploy/Models/Templates/DeploymentPlanTemplateParameters.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Models/Templates/DeploymentPlanTemplateParameters.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace AzureMcp.Areas.Deploy.Models.Templates; +namespace AzureMcp.Deploy.Models.Templates; /// /// Parameters for generating deployment plan templates. diff --git a/src/Areas/Deploy/Models/Templates/IaCRulesTemplateParameters.cs b/areas/deploy/src/AzureMcp.Deploy/Models/Templates/IaCRulesTemplateParameters.cs similarity index 98% rename from src/Areas/Deploy/Models/Templates/IaCRulesTemplateParameters.cs rename to areas/deploy/src/AzureMcp.Deploy/Models/Templates/IaCRulesTemplateParameters.cs index 58e19945c..60c4a3bd8 100644 --- a/src/Areas/Deploy/Models/Templates/IaCRulesTemplateParameters.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Models/Templates/IaCRulesTemplateParameters.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace AzureMcp.Areas.Deploy.Models.Templates; +namespace AzureMcp.Deploy.Models.Templates; /// /// Parameters for IaC rules template generation. diff --git a/src/Areas/Deploy/Options/AppTopology.cs b/areas/deploy/src/AzureMcp.Deploy/Options/AppTopology.cs similarity index 96% rename from src/Areas/Deploy/Options/AppTopology.cs rename to areas/deploy/src/AzureMcp.Deploy/Options/AppTopology.cs index 57e8c1e20..8cc2e7a7b 100644 --- a/src/Areas/Deploy/Options/AppTopology.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Options/AppTopology.cs @@ -2,9 +2,9 @@ // Licensed under the MIT License. using System.Text.Json.Serialization; -using AzureMcp.Options; +using AzureMcp.Core.Options; -namespace AzureMcp.Areas.Deploy.Options; +namespace AzureMcp.Deploy.Options; public class AppTopology { diff --git a/src/Areas/Deploy/Options/DeployAppLogOptions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/DeployAppLogOptions.cs similarity index 87% rename from src/Areas/Deploy/Options/DeployAppLogOptions.cs rename to areas/deploy/src/AzureMcp.Deploy/Options/DeployAppLogOptions.cs index 2d1700f69..4b8f4df8f 100644 --- a/src/Areas/Deploy/Options/DeployAppLogOptions.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Options/DeployAppLogOptions.cs @@ -2,9 +2,9 @@ // Licensed under the MIT License. using System.Text.Json.Serialization; -using AzureMcp.Options; +using AzureMcp.Core.Options; -namespace AzureMcp.Areas.Deploy.Options; +namespace AzureMcp.Deploy.Options; public class AzdAppLogOptions : SubscriptionOptions { diff --git a/src/Areas/Deploy/Options/DeployOptionDefinitions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/DeployOptionDefinitions.cs similarity index 98% rename from src/Areas/Deploy/Options/DeployOptionDefinitions.cs rename to areas/deploy/src/AzureMcp.Deploy/Options/DeployOptionDefinitions.cs index 7d232b619..691b7be26 100644 --- a/src/Areas/Deploy/Options/DeployOptionDefinitions.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Options/DeployOptionDefinitions.cs @@ -2,11 +2,11 @@ // Licensed under the MIT License. using System.Text.Json.Nodes; -using AzureMcp.Areas.Server.Commands; -using AzureMcp.Areas.Server.Commands.ToolLoading; -using AzureMcp.Options; +using AzureMcp.Core.Areas.Server.Commands; +using AzureMcp.Core.Areas.Server.Commands.ToolLoading; +using AzureMcp.Core.Options; -namespace AzureMcp.Areas.Deploy.Options; +namespace AzureMcp.Deploy.Options; public static class DeployOptionDefinitions { diff --git a/src/Areas/Deploy/Options/InfraCodeRulesOptions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/InfraCodeRulesOptions.cs similarity index 88% rename from src/Areas/Deploy/Options/InfraCodeRulesOptions.cs rename to areas/deploy/src/AzureMcp.Deploy/Options/InfraCodeRulesOptions.cs index e815f4900..01c170207 100644 --- a/src/Areas/Deploy/Options/InfraCodeRulesOptions.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Options/InfraCodeRulesOptions.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace AzureMcp.Areas.Deploy.Options; +namespace AzureMcp.Deploy.Options; public sealed class InfraCodeRulesOptions { diff --git a/src/Areas/Deploy/Options/PipelineGenerateOptions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/PipelineGenerateOptions.cs similarity index 89% rename from src/Areas/Deploy/Options/PipelineGenerateOptions.cs rename to areas/deploy/src/AzureMcp.Deploy/Options/PipelineGenerateOptions.cs index 35b8ed9d4..df9bbb4b7 100644 --- a/src/Areas/Deploy/Options/PipelineGenerateOptions.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Options/PipelineGenerateOptions.cs @@ -2,9 +2,9 @@ // Licensed under the MIT License. using System.Text.Json.Serialization; -using AzureMcp.Options; +using AzureMcp.Core.Options; -namespace AzureMcp.Areas.Deploy.Options; +namespace AzureMcp.Deploy.Options; public class PipelineGenerateOptions : SubscriptionOptions { diff --git a/src/Areas/Deploy/Options/PlanGetOptions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/PlanGetOptions.cs similarity index 94% rename from src/Areas/Deploy/Options/PlanGetOptions.cs rename to areas/deploy/src/AzureMcp.Deploy/Options/PlanGetOptions.cs index cc075e125..4264f0e71 100644 --- a/src/Areas/Deploy/Options/PlanGetOptions.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Options/PlanGetOptions.cs @@ -3,7 +3,7 @@ using System.Text.Json.Serialization; -namespace AzureMcp.Areas.Deploy.Options; +namespace AzureMcp.Deploy.Options; public sealed class PlanGetOptions { diff --git a/src/Areas/Deploy/Options/RawMcpToolInputOptions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/RawMcpToolInputOptions.cs similarity index 64% rename from src/Areas/Deploy/Options/RawMcpToolInputOptions.cs rename to areas/deploy/src/AzureMcp.Deploy/Options/RawMcpToolInputOptions.cs index 32c973f91..3ec626204 100644 --- a/src/Areas/Deploy/Options/RawMcpToolInputOptions.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Options/RawMcpToolInputOptions.cs @@ -2,11 +2,11 @@ // Licensed under the MIT License. using System.Text.Json.Serialization; -using AzureMcp.Areas.Server.Commands; -using AzureMcp.Areas.Server.Commands.ToolLoading; -using AzureMcp.Options; +using AzureMcp.Core.Areas.Server.Commands; +using AzureMcp.Core.Areas.Server.Commands.ToolLoading; +using AzureMcp.Core.Options; -namespace AzureMcp.Areas.Deploy.Options; +namespace AzureMcp.Deploy.Options; public class RawMcpToolInputOptions : GlobalOptions { diff --git a/src/Areas/Deploy/Services/DeployService.cs b/areas/deploy/src/AzureMcp.Deploy/Services/DeployService.cs similarity index 92% rename from src/Areas/Deploy/Services/DeployService.cs rename to areas/deploy/src/AzureMcp.Deploy/Services/DeployService.cs index f1a701aea..47224d78e 100644 --- a/src/Areas/Deploy/Services/DeployService.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Services/DeployService.cs @@ -4,9 +4,9 @@ using System.Diagnostics.CodeAnalysis; using Areas.Deploy.Services.Util; using Azure.Core; -using AzureMcp.Services.Azure; +using AzureMcp.Core.Services.Azure; -namespace AzureMcp.Areas.Deploy.Services; +namespace AzureMcp.Deploy.Services; public class DeployService() : BaseAzureService, IDeployService { diff --git a/src/Areas/Deploy/Services/IDeployService.cs b/areas/deploy/src/AzureMcp.Deploy/Services/IDeployService.cs similarity index 72% rename from src/Areas/Deploy/Services/IDeployService.cs rename to areas/deploy/src/AzureMcp.Deploy/Services/IDeployService.cs index 7c223c929..25b368f11 100644 --- a/src/Areas/Deploy/Services/IDeployService.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Services/IDeployService.cs @@ -1,10 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using AzureMcp.Areas.Deploy.Models; -using AzureMcp.Options; +using AzureMcp.Core.Options; +using AzureMcp.Deploy.Models; -namespace AzureMcp.Areas.Deploy.Services; +namespace AzureMcp.Deploy.Services; public interface IDeployService { diff --git a/src/Areas/Deploy/Services/Templates/TemplateService.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Templates/TemplateService.cs similarity index 95% rename from src/Areas/Deploy/Services/Templates/TemplateService.cs rename to areas/deploy/src/AzureMcp.Deploy/Services/Templates/TemplateService.cs index f93205d0b..62acbbfbc 100644 --- a/src/Areas/Deploy/Services/Templates/TemplateService.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Services/Templates/TemplateService.cs @@ -4,7 +4,7 @@ using System.Reflection; using System.Text; -namespace AzureMcp.Areas.Deploy.Services.Templates; +namespace AzureMcp.Deploy.Services.Templates; /// /// Service for loading and processing embedded template resources. @@ -12,7 +12,7 @@ namespace AzureMcp.Areas.Deploy.Services.Templates; public static class TemplateService { private static readonly Assembly _assembly = Assembly.GetExecutingAssembly(); - private const string TemplateNamespace = "AzureMcp.Areas.Deploy.Templates"; + private const string TemplateNamespace = "AzureMcp.Deploy.Templates"; /// /// Loads an embedded template resource by name. diff --git a/src/Areas/Deploy/Services/Util/AzdAppLogRetriever.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdAppLogRetriever.cs similarity index 100% rename from src/Areas/Deploy/Services/Util/AzdAppLogRetriever.cs rename to areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdAppLogRetriever.cs diff --git a/src/Areas/Deploy/Services/Util/AzdResourceLogService.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs similarity index 100% rename from src/Areas/Deploy/Services/Util/AzdResourceLogService.cs rename to areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs diff --git a/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/DeploymentPlanTemplateUtil.cs similarity index 97% rename from src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs rename to areas/deploy/src/AzureMcp.Deploy/Services/Util/DeploymentPlanTemplateUtil.cs index b29bab43a..545abf9d6 100644 --- a/src/Areas/Deploy/Services/Util/DeploymentPlanTemplateUtil.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Services/Util/DeploymentPlanTemplateUtil.cs @@ -1,11 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using AzureMcp.Areas.Deploy.Models; -using AzureMcp.Areas.Deploy.Models.Templates; -using AzureMcp.Areas.Deploy.Services.Templates; +using AzureMcp.Deploy.Models; +using AzureMcp.Deploy.Models.Templates; +using AzureMcp.Deploy.Services.Templates; -namespace AzureMcp.Areas.Deploy.Services.Util; +namespace AzureMcp.Deploy.Services.Util; /// /// Refactored utility class for generating deployment plan templates using embedded resources. diff --git a/src/Areas/Deploy/Services/Util/IaCRulesTemplateUtil.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/IaCRulesTemplateUtil.cs similarity index 98% rename from src/Areas/Deploy/Services/Util/IaCRulesTemplateUtil.cs rename to areas/deploy/src/AzureMcp.Deploy/Services/Util/IaCRulesTemplateUtil.cs index 3af087b5c..6eadcc84c 100644 --- a/src/Areas/Deploy/Services/Util/IaCRulesTemplateUtil.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Services/Util/IaCRulesTemplateUtil.cs @@ -1,11 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using AzureMcp.Areas.Deploy.Models; -using AzureMcp.Areas.Deploy.Models.Templates; -using AzureMcp.Areas.Deploy.Services.Templates; +using AzureMcp.Deploy.Models; +using AzureMcp.Deploy.Models.Templates; +using AzureMcp.Deploy.Services.Templates; -namespace AzureMcp.Areas.Deploy.Services.Util; +namespace AzureMcp.Deploy.Services.Util; /// /// Utility class for generating IaC rules using embedded templates. diff --git a/src/Areas/Deploy/Services/Util/JsonElementHelper.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/JsonElementHelper.cs similarity index 100% rename from src/Areas/Deploy/Services/Util/JsonElementHelper.cs rename to areas/deploy/src/AzureMcp.Deploy/Services/Util/JsonElementHelper.cs diff --git a/src/Areas/Deploy/Services/Util/PipelineGenerationUtil.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/PipelineGenerationUtil.cs similarity index 98% rename from src/Areas/Deploy/Services/Util/PipelineGenerationUtil.cs rename to areas/deploy/src/AzureMcp.Deploy/Services/Util/PipelineGenerationUtil.cs index c84e72c6e..0fc02756e 100644 --- a/src/Areas/Deploy/Services/Util/PipelineGenerationUtil.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Services/Util/PipelineGenerationUtil.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using AzureMcp.Areas.Deploy.Options; +using AzureMcp.Deploy.Options; -namespace AzureMcp.Areas.Deploy.Services.Util; +namespace AzureMcp.Deploy.Services.Util; public static class PipelineGenerationUtil { diff --git a/src/Areas/Deploy/Templates/IaCRules/appservice-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/appservice-rules.md similarity index 100% rename from src/Areas/Deploy/Templates/IaCRules/appservice-rules.md rename to areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/appservice-rules.md diff --git a/src/Areas/Deploy/Templates/IaCRules/azcli-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/azcli-rules.md similarity index 80% rename from src/Areas/Deploy/Templates/IaCRules/azcli-rules.md rename to areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/azcli-rules.md index 2487d9fe0..1b77c56d8 100644 --- a/src/Areas/Deploy/Templates/IaCRules/azcli-rules.md +++ b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/azcli-rules.md @@ -1,4 +1,4 @@ - If creating AzCli script, the script should stop if any command fails. Fix the error before proceeding. -- Azure resource Naming: All resources should be named like {resourcePrefix}{resourceToken}{instance}. Alphanumeric only! Don't use special characters! resourcePrefix is a prefix for the resource (ex. 'kv' for key vault) and <= 3 characters. resourceToken is a random string and = 5 characters. It should be used for all azure resources in a resource group. instance is a number that can be used when there are multiple resource with the same type. For example, resourceToken=abcde, then resource name: rgabcde(resource group), kvabvcde(keyvault), sqlabcde(sql), appabcde1(container app 1), appabcde2(container app 2). +- Azure resource Naming: All resources should be named like {resourcePrefix}{resourceToken}{instance}. Alphanumeric only! Don't use special characters! resourcePrefix is a prefix for the resource (ex. 'kv' for key vault) and <= 3 characters. resourceToken is a random string and = 5 characters. It should be used for all azure resources in a resource group. instance is a number that can be used when there are multiple resource with the same type. For example, resourceToken=abcde, then resource name: myRg(resource group), myKv(keyvault), myServer(sql), myApp1(container app 1), myApp2(container app 2). - Kubernetes (K8s) YAML naming: only Lowercase letters (a-z), digits (0-9), hyphens (-) is allowed. Must start and end with a letter or digit. Less than 20 characters. diff --git a/src/Areas/Deploy/Templates/IaCRules/azd-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/azd-rules.md similarity index 100% rename from src/Areas/Deploy/Templates/IaCRules/azd-rules.md rename to areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/azd-rules.md diff --git a/src/Areas/Deploy/Templates/IaCRules/base-iac-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/base-iac-rules.md similarity index 100% rename from src/Areas/Deploy/Templates/IaCRules/base-iac-rules.md rename to areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/base-iac-rules.md diff --git a/src/Areas/Deploy/Templates/IaCRules/bicep-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/bicep-rules.md similarity index 100% rename from src/Areas/Deploy/Templates/IaCRules/bicep-rules.md rename to areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/bicep-rules.md diff --git a/src/Areas/Deploy/Templates/IaCRules/containerapp-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/containerapp-rules.md similarity index 100% rename from src/Areas/Deploy/Templates/IaCRules/containerapp-rules.md rename to areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/containerapp-rules.md diff --git a/src/Areas/Deploy/Templates/IaCRules/final-instructions.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/final-instructions.md similarity index 100% rename from src/Areas/Deploy/Templates/IaCRules/final-instructions.md rename to areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/final-instructions.md diff --git a/src/Areas/Deploy/Templates/IaCRules/functionapp-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/functionapp-rules.md similarity index 100% rename from src/Areas/Deploy/Templates/IaCRules/functionapp-rules.md rename to areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/functionapp-rules.md diff --git a/src/Areas/Deploy/Templates/IaCRules/terraform-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/terraform-rules.md similarity index 100% rename from src/Areas/Deploy/Templates/IaCRules/terraform-rules.md rename to areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/terraform-rules.md diff --git a/src/Areas/Deploy/Templates/Plan/aks-steps.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/aks-steps.md similarity index 100% rename from src/Areas/Deploy/Templates/Plan/aks-steps.md rename to areas/deploy/src/AzureMcp.Deploy/Templates/Plan/aks-steps.md diff --git a/src/Areas/Deploy/Templates/Plan/azcli-steps.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/azcli-steps.md similarity index 100% rename from src/Areas/Deploy/Templates/Plan/azcli-steps.md rename to areas/deploy/src/AzureMcp.Deploy/Templates/Plan/azcli-steps.md diff --git a/src/Areas/Deploy/Templates/Plan/azd-steps.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/azd-steps.md similarity index 100% rename from src/Areas/Deploy/Templates/Plan/azd-steps.md rename to areas/deploy/src/AzureMcp.Deploy/Templates/Plan/azd-steps.md diff --git a/src/Areas/Deploy/Templates/Plan/containerapp-steps.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/containerapp-steps.md similarity index 100% rename from src/Areas/Deploy/Templates/Plan/containerapp-steps.md rename to areas/deploy/src/AzureMcp.Deploy/Templates/Plan/containerapp-steps.md diff --git a/src/Areas/Deploy/Templates/Plan/deployment-plan-base.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/deployment-plan-base.md similarity index 100% rename from src/Areas/Deploy/Templates/Plan/deployment-plan-base.md rename to areas/deploy/src/AzureMcp.Deploy/Templates/Plan/deployment-plan-base.md diff --git a/src/Areas/Deploy/Templates/Plan/summary-steps.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/summary-steps.md similarity index 100% rename from src/Areas/Deploy/Templates/Plan/summary-steps.md rename to areas/deploy/src/AzureMcp.Deploy/Templates/Plan/summary-steps.md diff --git a/areas/deploy/tests/AzureMcp.Deploy.LiveTests/AzureMcp.Deploy.LiveTests.csproj b/areas/deploy/tests/AzureMcp.Deploy.LiveTests/AzureMcp.Deploy.LiveTests.csproj new file mode 100644 index 000000000..2ef019eb3 --- /dev/null +++ b/areas/deploy/tests/AzureMcp.Deploy.LiveTests/AzureMcp.Deploy.LiveTests.csproj @@ -0,0 +1,17 @@ + + + true + Exe + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Areas/Deploy/LiveTests/DeployCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.LiveTests/DeployCommandTests.cs similarity index 94% rename from tests/Areas/Deploy/LiveTests/DeployCommandTests.cs rename to areas/deploy/tests/AzureMcp.Deploy.LiveTests/DeployCommandTests.cs index 63e6cda87..0665a147e 100644 --- a/tests/Areas/Deploy/LiveTests/DeployCommandTests.cs +++ b/areas/deploy/tests/AzureMcp.Deploy.LiveTests/DeployCommandTests.cs @@ -3,16 +3,15 @@ using System.Text.Json; using System.Text.Json.Nodes; -using AzureMcp.Areas.Deploy.Models; -using AzureMcp.Areas.Deploy.Services; +using AzureMcp.Deploy.Models; +using AzureMcp.Deploy.Services; using AzureMcp.Tests.Client; using AzureMcp.Tests.Client.Helpers; using ModelContextProtocol.Client; using Xunit; -namespace AzureMcp.Tests.Areas.Deploy.LiveTests; +namespace AzureMcp.Deploy.LiveTests; -[Trait("Area", "Deploy")] public class DeployCommandTests : CommandTestsBase, IClassFixture { @@ -24,7 +23,6 @@ public DeployCommandTests(LiveTestFixture liveTestFixture, ITestOutputHelper out } [Fact] - [Trait("Category", "Live")] public async Task Should_get_plan() { // act @@ -43,7 +41,6 @@ public async Task Should_get_plan() } [Fact] - [Trait("Category", "Live")] public async Task Should_get_infrastructure_code_rules() { // arrange @@ -68,7 +65,6 @@ public async Task Should_get_infrastructure_code_rules() } [Fact] - [Trait("Category", "Live")] public async Task Should_get_infrastructure_rules_for_terraform() { // act @@ -86,7 +82,6 @@ public async Task Should_get_infrastructure_rules_for_terraform() } [Fact] - [Trait("Category", "Live")] public async Task Should_generate_pipeline() { // act @@ -103,7 +98,6 @@ public async Task Should_generate_pipeline() } [Fact] - [Trait("Category", "Live")] public async Task Should_generate_pipeline_with_github_details() { // act @@ -124,7 +118,6 @@ public async Task Should_generate_pipeline_with_github_details() [Fact] - [Trait("Category", "Live")] public async Task Should_get_azd_app_logs() { // act diff --git a/tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/ArchitectureDiagramTests.cs similarity index 96% rename from tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs rename to areas/deploy/tests/AzureMcp.Deploy.UnitTests/ArchitectureDiagramTests.cs index 7af384ccd..5037327f3 100644 --- a/tests/Areas/Deploy/UnitTests/ArchitectureDiagramTests.cs +++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/ArchitectureDiagramTests.cs @@ -5,17 +5,17 @@ using System.CommandLine.Parsing; using System.Text.Json; using System.Text.Json.Serialization; -using AzureMcp.Areas.Deploy.Commands; -using AzureMcp.Areas.Deploy.Options; -using AzureMcp.Models.Command; +using AzureMcp.Core.Models.Command; +using AzureMcp.Deploy.Commands; +using AzureMcp.Deploy.Options; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; -namespace AzureMcp.Tests.Areas.Deploy.UnitTests; +namespace AzureMcp.Deploy.UnitTests; + -[Trait("Area", "Deploy")] public class ArchitectureDiagramTests { private readonly IServiceProvider _serviceProvider; diff --git a/tests/Areas/Deploy/UnitTests/AzdAppLogGetCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/AzdAppLogGetCommandTests.cs similarity index 97% rename from tests/Areas/Deploy/UnitTests/AzdAppLogGetCommandTests.cs rename to areas/deploy/tests/AzureMcp.Deploy.UnitTests/AzdAppLogGetCommandTests.cs index fbc02d335..b4ec9d3d6 100644 --- a/tests/Areas/Deploy/UnitTests/AzdAppLogGetCommandTests.cs +++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/AzdAppLogGetCommandTests.cs @@ -2,18 +2,18 @@ // Licensed under the MIT License. using System.CommandLine.Parsing; -using AzureMcp.Areas.Deploy.Commands; -using AzureMcp.Areas.Deploy.Services; -using AzureMcp.Models.Command; +using AzureMcp.Core.Models.Command; +using AzureMcp.Deploy.Commands; +using AzureMcp.Deploy.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ExceptionExtensions; using Xunit; -namespace AzureMcp.Tests.Areas.Deploy.UnitTests; +namespace AzureMcp.Deploy.UnitTests; + -[Trait("Area", "Deploy")] public class AzdAppLogGetCommandTests { private readonly IServiceProvider _serviceProvider; diff --git a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/AzureMcp.Deploy.UnitTests.csproj b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/AzureMcp.Deploy.UnitTests.csproj new file mode 100644 index 000000000..f2cc61643 --- /dev/null +++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/AzureMcp.Deploy.UnitTests.csproj @@ -0,0 +1,17 @@ + + + Exe + true + + + + + + + + + + + + + diff --git a/tests/Areas/Deploy/UnitTests/DeploymentPlanTemplateUtilV2Tests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/DeploymentPlanTemplateUtilV2Tests.cs similarity index 97% rename from tests/Areas/Deploy/UnitTests/DeploymentPlanTemplateUtilV2Tests.cs rename to areas/deploy/tests/AzureMcp.Deploy.UnitTests/DeploymentPlanTemplateUtilV2Tests.cs index 8a8ccc9a3..b2fb73e43 100644 --- a/tests/Areas/Deploy/UnitTests/DeploymentPlanTemplateUtilV2Tests.cs +++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/DeploymentPlanTemplateUtilV2Tests.cs @@ -1,10 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using AzureMcp.Areas.Deploy.Services.Util; +using AzureMcp.Deploy.Services.Util; using Xunit; -namespace AzureMcp.Tests.Areas.Deploy.Services.Util; +namespace AzureMcp.Deploy.Services.Util; public sealed class DeploymentPlanTemplateUtilV2Tests { diff --git a/tests/Areas/Deploy/UnitTests/IaCRulesGetCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/IaCRulesGetCommandTests.cs similarity index 97% rename from tests/Areas/Deploy/UnitTests/IaCRulesGetCommandTests.cs rename to areas/deploy/tests/AzureMcp.Deploy.UnitTests/IaCRulesGetCommandTests.cs index 24581631b..21ac10ea8 100644 --- a/tests/Areas/Deploy/UnitTests/IaCRulesGetCommandTests.cs +++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/IaCRulesGetCommandTests.cs @@ -2,16 +2,16 @@ // Licensed under the MIT License. using System.CommandLine.Parsing; -using AzureMcp.Areas.Deploy.Commands.InfraCodeRules; -using AzureMcp.Models.Command; +using AzureMcp.Core.Models.Command; +using AzureMcp.Deploy.Commands.InfraCodeRules; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; -namespace AzureMcp.Tests.Areas.Deploy.UnitTests; +namespace AzureMcp.Deploy.UnitTests; + -[Trait("Area", "Deploy")] public class IaCRulesGetCommandTests { private readonly IServiceProvider _serviceProvider; diff --git a/tests/Areas/Deploy/UnitTests/PipelineGenerateCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/PipelineGenerateCommandTests.cs similarity index 97% rename from tests/Areas/Deploy/UnitTests/PipelineGenerateCommandTests.cs rename to areas/deploy/tests/AzureMcp.Deploy.UnitTests/PipelineGenerateCommandTests.cs index c9397902f..a87b2c756 100644 --- a/tests/Areas/Deploy/UnitTests/PipelineGenerateCommandTests.cs +++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/PipelineGenerateCommandTests.cs @@ -2,16 +2,16 @@ // Licensed under the MIT License. using System.CommandLine.Parsing; -using AzureMcp.Areas.Deploy.Commands; -using AzureMcp.Models.Command; +using AzureMcp.Core.Models.Command; +using AzureMcp.Deploy.Commands; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; -namespace AzureMcp.Tests.Areas.Deploy.UnitTests; +namespace AzureMcp.Deploy.UnitTests; + -[Trait("Area", "Deploy")] public class PipelineGenerateCommandTests { private readonly IServiceProvider _serviceProvider; diff --git a/tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/PlanGetCommandTests.cs similarity index 96% rename from tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs rename to areas/deploy/tests/AzureMcp.Deploy.UnitTests/PlanGetCommandTests.cs index 5a345f256..453b069d0 100644 --- a/tests/Areas/Deploy/UnitTests/PlanGetCommandTests.cs +++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/PlanGetCommandTests.cs @@ -2,16 +2,16 @@ // Licensed under the MIT License. using System.CommandLine.Parsing; -using AzureMcp.Areas.Deploy.Commands.Plan; -using AzureMcp.Models.Command; +using AzureMcp.Core.Models.Command; +using AzureMcp.Deploy.Commands.Plan; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; -namespace AzureMcp.Tests.Areas.Deploy.UnitTests; +namespace AzureMcp.Deploy.UnitTests; + -[Trait("Area", "Deploy")] public class PlanGetCommandTests { private readonly IServiceProvider _serviceProvider; diff --git a/tests/Areas/Deploy/UnitTests/TemplateServiceTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/TemplateServiceTests.cs similarity index 95% rename from tests/Areas/Deploy/UnitTests/TemplateServiceTests.cs rename to areas/deploy/tests/AzureMcp.Deploy.UnitTests/TemplateServiceTests.cs index 656b3d9d5..2d2bf51f2 100644 --- a/tests/Areas/Deploy/UnitTests/TemplateServiceTests.cs +++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/TemplateServiceTests.cs @@ -1,10 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using AzureMcp.Areas.Deploy.Services.Templates; +using AzureMcp.Deploy.Services.Templates; using Xunit; -namespace AzureMcp.Tests.Areas.Deploy.Services.Templates; +namespace AzureMcp.Deploy.Services.Templates; public sealed class TemplateServiceTests { diff --git a/areas/quota/src/AzureMcp.Quota/AssemblyInfo.cs b/areas/quota/src/AzureMcp.Quota/AssemblyInfo.cs new file mode 100644 index 000000000..8eb69fad7 --- /dev/null +++ b/areas/quota/src/AzureMcp.Quota/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("AzureMcp.AppConfig.UnitTests")] +[assembly: InternalsVisibleTo("AzureMcp.AppConfig.LiveTests")] diff --git a/areas/quota/src/AzureMcp.Quota/AzureMcp.Quota.csproj b/areas/quota/src/AzureMcp.Quota/AzureMcp.Quota.csproj new file mode 100644 index 000000000..c3b8864a7 --- /dev/null +++ b/areas/quota/src/AzureMcp.Quota/AzureMcp.Quota.csproj @@ -0,0 +1,33 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Areas/Quota/Commands/QuotaJsonContext.cs b/areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs similarity index 84% rename from src/Areas/Quota/Commands/QuotaJsonContext.cs rename to areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs index 3c5396683..869c89990 100644 --- a/src/Areas/Quota/Commands/QuotaJsonContext.cs +++ b/areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs @@ -2,10 +2,10 @@ // Licensed under the MIT License. using System.Text.Json.Serialization; -using AzureMcp.Areas.Quota.Commands; -using AzureMcp.Areas.Quota.Services.Util; +using AzureMcp.Quota.Commands; +using AzureMcp.Quota.Services.Util; -namespace AzureMcp.Areas.Quota.Commands; +namespace AzureMcp.Quota.Commands; [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, diff --git a/src/Areas/Quota/Commands/RegionCheckCommand.cs b/areas/quota/src/AzureMcp.Quota/Commands/RegionCheckCommand.cs similarity index 90% rename from src/Areas/Quota/Commands/RegionCheckCommand.cs rename to areas/quota/src/AzureMcp.Quota/Commands/RegionCheckCommand.cs index d99a570fb..a5f6d11c6 100644 --- a/src/Areas/Quota/Commands/RegionCheckCommand.cs +++ b/areas/quota/src/AzureMcp.Quota/Commands/RegionCheckCommand.cs @@ -1,15 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using AzureMcp.Areas.Quota.Options; -using AzureMcp.Areas.Quota.Services; -using AzureMcp.Commands; -using AzureMcp.Commands.Subscription; -using AzureMcp.Models.Command; -using AzureMcp.Services.Telemetry; +using AzureMcp.Core.Commands; +using AzureMcp.Core.Commands.Subscription; +using AzureMcp.Core.Models.Command; +using AzureMcp.Core.Services.Telemetry; +using AzureMcp.Quota.Options; +using AzureMcp.Quota.Services; using Microsoft.Extensions.Logging; -namespace AzureMcp.Areas.Quota.Commands; +namespace AzureMcp.Quota.Commands; public sealed class RegionCheckCommand(ILogger logger) : SubscriptionCommand() { @@ -29,6 +29,7 @@ public sealed class RegionCheckCommand(ILogger logger) : Sub """; public override string Title => CommandTitle; + public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true }; protected override void RegisterOptions(Command command) { @@ -49,10 +50,6 @@ protected override RegionCheckOptions BindOptions(ParseResult parseResult) return options; } - [McpServerTool( - Destructive = false, - ReadOnly = true, - Title = CommandTitle)] public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) { var options = BindOptions(parseResult); @@ -101,5 +98,5 @@ public override async Task ExecuteAsync(CommandContext context, return context.Response; } - internal record RegionCheckCommandResult(List AvailableRegions); + public record RegionCheckCommandResult(List AvailableRegions); } diff --git a/src/Areas/Quota/Commands/UsageCheckCommand.cs b/areas/quota/src/AzureMcp.Quota/Commands/UsageCheckCommand.cs similarity index 85% rename from src/Areas/Quota/Commands/UsageCheckCommand.cs rename to areas/quota/src/AzureMcp.Quota/Commands/UsageCheckCommand.cs index 728223db3..cf06cd6b7 100644 --- a/src/Areas/Quota/Commands/UsageCheckCommand.cs +++ b/areas/quota/src/AzureMcp.Quota/Commands/UsageCheckCommand.cs @@ -1,16 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using AzureMcp.Areas.Quota.Options; -using AzureMcp.Areas.Quota.Services; -using AzureMcp.Areas.Quota.Services.Util; -using AzureMcp.Commands; -using AzureMcp.Commands.Subscription; -using AzureMcp.Models.Command; -using AzureMcp.Services.Telemetry; +using AzureMcp.Core.Commands; +using AzureMcp.Core.Commands.Subscription; +using AzureMcp.Core.Models.Command; +using AzureMcp.Core.Services.Telemetry; +using AzureMcp.Quota.Options; +using AzureMcp.Quota.Services; +using AzureMcp.Quota.Services.Util; using Microsoft.Extensions.Logging; -namespace AzureMcp.Areas.Quota.Commands; +namespace AzureMcp.Quota.Commands; public class UsageCheckCommand(ILogger logger) : SubscriptionCommand() { @@ -28,6 +28,7 @@ public class UsageCheckCommand(ILogger logger) : Subscription """; public override string Title => CommandTitle; + public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true }; protected override void RegisterOptions(Command command) { @@ -44,10 +45,6 @@ protected override UsageCheckOptions BindOptions(ParseResult parseResult) return options; } - [McpServerTool( - Destructive = false, - ReadOnly = true, - Title = CommandTitle)] public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) { var options = BindOptions(parseResult); @@ -87,6 +84,6 @@ public override async Task ExecuteAsync(CommandContext context, } - internal record UsageCheckCommandResult(Dictionary> UsageInfo); + public record UsageCheckCommandResult(Dictionary> UsageInfo); } diff --git a/areas/quota/src/AzureMcp.Quota/GlobalUsings.cs b/areas/quota/src/AzureMcp.Quota/GlobalUsings.cs new file mode 100644 index 000000000..85a476736 --- /dev/null +++ b/areas/quota/src/AzureMcp.Quota/GlobalUsings.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +global using System.CommandLine; +global using System.CommandLine.Parsing; +global using System.Text.Json; +global using AzureMcp.Core.Extensions; +global using AzureMcp.Core.Models; +global using AzureMcp.Core.Models.Command; +global using ModelContextProtocol.Server; diff --git a/src/Areas/Quota/Models/CognitiveServiceProperties.cs b/areas/quota/src/AzureMcp.Quota/Models/CognitiveServiceProperties.cs similarity index 88% rename from src/Areas/Quota/Models/CognitiveServiceProperties.cs rename to areas/quota/src/AzureMcp.Quota/Models/CognitiveServiceProperties.cs index c0cafb50e..713296c4b 100644 --- a/src/Areas/Quota/Models/CognitiveServiceProperties.cs +++ b/areas/quota/src/AzureMcp.Quota/Models/CognitiveServiceProperties.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace AzureMcp.Areas.Quota.Models; +namespace AzureMcp.Quota.Models; public class CognitiveServiceProperties { diff --git a/src/Areas/Quota/Options/QuotaOptionDefinitions.cs b/areas/quota/src/AzureMcp.Quota/Options/QuotaOptionDefinitions.cs similarity index 98% rename from src/Areas/Quota/Options/QuotaOptionDefinitions.cs rename to areas/quota/src/AzureMcp.Quota/Options/QuotaOptionDefinitions.cs index 21d0de1f6..b577fb2ca 100644 --- a/src/Areas/Quota/Options/QuotaOptionDefinitions.cs +++ b/areas/quota/src/AzureMcp.Quota/Options/QuotaOptionDefinitions.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace AzureMcp.Areas.Quota.Options; +namespace AzureMcp.Quota.Options; public static class QuotaOptionDefinitions { diff --git a/src/Areas/Quota/Options/RegionCheckOptions.cs b/areas/quota/src/AzureMcp.Quota/Options/RegionCheckOptions.cs similarity index 90% rename from src/Areas/Quota/Options/RegionCheckOptions.cs rename to areas/quota/src/AzureMcp.Quota/Options/RegionCheckOptions.cs index ebe9d8e48..fc3c2e24c 100644 --- a/src/Areas/Quota/Options/RegionCheckOptions.cs +++ b/areas/quota/src/AzureMcp.Quota/Options/RegionCheckOptions.cs @@ -2,9 +2,9 @@ // Licensed under the MIT License. using System.Text.Json.Serialization; -using AzureMcp.Options; +using AzureMcp.Core.Options; -namespace AzureMcp.Areas.Quota.Options; +namespace AzureMcp.Quota.Options; public sealed class RegionCheckOptions : SubscriptionOptions { diff --git a/src/Areas/Quota/Options/UsageCheckOptions.cs b/areas/quota/src/AzureMcp.Quota/Options/UsageCheckOptions.cs similarity index 85% rename from src/Areas/Quota/Options/UsageCheckOptions.cs rename to areas/quota/src/AzureMcp.Quota/Options/UsageCheckOptions.cs index 1fdfe93f4..0b7fd04b6 100644 --- a/src/Areas/Quota/Options/UsageCheckOptions.cs +++ b/areas/quota/src/AzureMcp.Quota/Options/UsageCheckOptions.cs @@ -2,9 +2,9 @@ // Licensed under the MIT License. using System.Text.Json.Serialization; -using AzureMcp.Options; +using AzureMcp.Core.Options; -namespace AzureMcp.Areas.Quota.Options; +namespace AzureMcp.Quota.Options; public sealed class UsageCheckOptions : SubscriptionOptions { diff --git a/src/Areas/Quota/QuotaSetup.cs b/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs similarity index 81% rename from src/Areas/Quota/QuotaSetup.cs rename to areas/quota/src/AzureMcp.Quota/QuotaSetup.cs index a9ade11fb..daa464473 100644 --- a/src/Areas/Quota/QuotaSetup.cs +++ b/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs @@ -1,15 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using AzureMcp.Areas.Quota.Commands; -using AzureMcp.Areas.Quota.Services; -using AzureMcp.Commands; +using AzureMcp.Core.Areas; +using AzureMcp.Core.Commands; +using AzureMcp.Quota.Commands; +using AzureMcp.Quota.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace AzureMcp.Areas.Quota; +namespace AzureMcp.Quota; -internal sealed class QuotaSetup : IAreaSetup +public sealed class QuotaSetup : IAreaSetup { public void ConfigureServices(IServiceCollection services) { diff --git a/src/Areas/Quota/Services/IQuotaService.cs b/areas/quota/src/AzureMcp.Quota/Services/IQuotaService.cs similarity index 87% rename from src/Areas/Quota/Services/IQuotaService.cs rename to areas/quota/src/AzureMcp.Quota/Services/IQuotaService.cs index a7fe99225..f0b510309 100644 --- a/src/Areas/Quota/Services/IQuotaService.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/IQuotaService.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using AzureMcp.Areas.Quota.Services.Util; +using AzureMcp.Quota.Services.Util; -namespace AzureMcp.Areas.Quota.Services; +namespace AzureMcp.Quota.Services; public interface IQuotaService { diff --git a/src/Areas/Quota/Services/QuotaService.cs b/areas/quota/src/AzureMcp.Quota/Services/QuotaService.cs similarity index 93% rename from src/Areas/Quota/Services/QuotaService.cs rename to areas/quota/src/AzureMcp.Quota/Services/QuotaService.cs index cca24f945..37f8d0ce4 100644 --- a/src/Areas/Quota/Services/QuotaService.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/QuotaService.cs @@ -3,11 +3,11 @@ using Azure.Core; using Azure.ResourceManager; -using AzureMcp.Areas.Quota.Models; -using AzureMcp.Areas.Quota.Services.Util; -using AzureMcp.Services.Azure; +using AzureMcp.Core.Services.Azure; +using AzureMcp.Quota.Models; +using AzureMcp.Quota.Services.Util; -namespace AzureMcp.Areas.Quota.Services; +namespace AzureMcp.Quota.Services; public class QuotaService() : BaseAzureService, IQuotaService { diff --git a/src/Areas/Quota/Services/Util/AzureRegionChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs similarity index 99% rename from src/Areas/Quota/Services/Util/AzureRegionChecker.cs rename to areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs index 266c490b8..a96da23f5 100644 --- a/src/Areas/Quota/Services/Util/AzureRegionChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs @@ -8,9 +8,9 @@ using Azure.ResourceManager.CognitiveServices.Models; using Azure.ResourceManager.PostgreSql.FlexibleServers; using Azure.ResourceManager.PostgreSql.FlexibleServers.Models; -using AzureMcp.Areas.Quota.Models; +using AzureMcp.Quota.Models; -namespace AzureMcp.Areas.Quota.Services.Util; +namespace AzureMcp.Quota.Services.Util; public interface IRegionChecker { diff --git a/src/Areas/Quota/Services/Util/AzureUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs similarity index 97% rename from src/Areas/Quota/Services/Util/AzureUsageChecker.cs rename to areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs index 48a63c289..47c046251 100644 --- a/src/Areas/Quota/Services/Util/AzureUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs @@ -1,10 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using System.Net.Http.Headers; using System.Text.Json; using Azure.Core; using Azure.ResourceManager; -using AzureMcp.Services.Azure.Authentication; +using AzureMcp.Core.Services.Azure.Authentication; -namespace AzureMcp.Areas.Quota.Services.Util; +namespace AzureMcp.Quota.Services.Util; // For simplicity, we currently apply a single rule for all Azure resource providers: // - Any resource provider not listed in the enum is treated as having no quota limitations. diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/JsonElementHelper.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/JsonElementHelper.cs new file mode 100644 index 000000000..15d68d518 --- /dev/null +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/JsonElementHelper.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AzureMcp.Quota.Services.Util; + +public static class JsonElementHelper +{ + public static string GetStringSafe(this JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString() ?? string.Empty, + JsonValueKind.Undefined => string.Empty, + JsonValueKind.Null => string.Empty, + _ => string.Empty + }; + } +} diff --git a/src/Areas/Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs similarity index 91% rename from src/Areas/Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs rename to areas/quota/src/AzureMcp.Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs index 1ae76d1e8..c07a04893 100644 --- a/src/Areas/Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs @@ -1,8 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using Azure.Core; using Azure.ResourceManager.CognitiveServices; using Azure.ResourceManager.CognitiveServices.Models; -namespace AzureMcp.Areas.Quota.Services.Util; +namespace AzureMcp.Quota.Services.Util; public class CognitiveServicesUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { diff --git a/src/Areas/Quota/Services/Util/Usage/ComputeUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ComputeUsageChecker.cs similarity index 91% rename from src/Areas/Quota/Services/Util/Usage/ComputeUsageChecker.cs rename to areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ComputeUsageChecker.cs index 913aec5b0..50e1a4325 100644 --- a/src/Areas/Quota/Services/Util/Usage/ComputeUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ComputeUsageChecker.cs @@ -1,9 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using Azure.Core; using Azure.ResourceManager; using Azure.ResourceManager.Compute; using Azure.ResourceManager.Compute.Models; -namespace AzureMcp.Areas.Quota.Services.Util; +namespace AzureMcp.Quota.Services.Util; public class ComputeUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { diff --git a/src/Areas/Quota/Services/Util/Usage/ContainerAppUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ContainerAppUsageChecker.cs similarity index 90% rename from src/Areas/Quota/Services/Util/Usage/ContainerAppUsageChecker.cs rename to areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ContainerAppUsageChecker.cs index f27a90df4..e68a7c73f 100644 --- a/src/Areas/Quota/Services/Util/Usage/ContainerAppUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ContainerAppUsageChecker.cs @@ -1,8 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using Azure.Core; using Azure.ResourceManager.AppContainers; using Azure.ResourceManager.AppContainers.Models; -namespace AzureMcp.Areas.Quota.Services.Util; +namespace AzureMcp.Quota.Services.Util; public class ContainerAppUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { diff --git a/src/Areas/Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs similarity index 91% rename from src/Areas/Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs rename to areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs index df9a811d7..7210bc79e 100644 --- a/src/Areas/Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs @@ -1,8 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using Azure.Core; using Azure.ResourceManager.ContainerInstance; using Azure.ResourceManager.ContainerInstance.Models; -namespace AzureMcp.Areas.Quota.Services.Util; +namespace AzureMcp.Quota.Services.Util; public class ContainerInstanceUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { diff --git a/src/Areas/Quota/Services/Util/Usage/HDInsightUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/HDInsightUsageChecker.cs similarity index 91% rename from src/Areas/Quota/Services/Util/Usage/HDInsightUsageChecker.cs rename to areas/quota/src/AzureMcp.Quota/Services/Util/Usage/HDInsightUsageChecker.cs index fd159512f..5e3fd13df 100644 --- a/src/Areas/Quota/Services/Util/Usage/HDInsightUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/HDInsightUsageChecker.cs @@ -1,8 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using Azure.Core; using Azure.ResourceManager.HDInsight; using Azure.ResourceManager.HDInsight.Models; -namespace AzureMcp.Areas.Quota.Services.Util; +namespace AzureMcp.Quota.Services.Util; public class HDInsightUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { diff --git a/src/Areas/Quota/Services/Util/Usage/MachineLearningUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/MachineLearningUsageChecker.cs similarity index 90% rename from src/Areas/Quota/Services/Util/Usage/MachineLearningUsageChecker.cs rename to areas/quota/src/AzureMcp.Quota/Services/Util/Usage/MachineLearningUsageChecker.cs index 8630ccc87..e393dc3e0 100644 --- a/src/Areas/Quota/Services/Util/Usage/MachineLearningUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/MachineLearningUsageChecker.cs @@ -1,7 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using Azure.Core; using Azure.ResourceManager.MachineLearning; -namespace AzureMcp.Areas.Quota.Services.Util; +namespace AzureMcp.Quota.Services.Util; public class MachineLearningUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { diff --git a/src/Areas/Quota/Services/Util/Usage/NetworkUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/NetworkUsageChecker.cs similarity index 90% rename from src/Areas/Quota/Services/Util/Usage/NetworkUsageChecker.cs rename to areas/quota/src/AzureMcp.Quota/Services/Util/Usage/NetworkUsageChecker.cs index f9b5d1460..369263a12 100644 --- a/src/Areas/Quota/Services/Util/Usage/NetworkUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/NetworkUsageChecker.cs @@ -1,7 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using Azure.Core; using Azure.ResourceManager.Network; -namespace AzureMcp.Areas.Quota.Services.Util; +namespace AzureMcp.Quota.Services.Util; public class NetworkUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { diff --git a/src/Areas/Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs similarity index 94% rename from src/Areas/Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs rename to areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs index 1af36bd98..98868a3a4 100644 --- a/src/Areas/Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs @@ -1,7 +1,9 @@ -using Areas.Server.Commands.Tools.DeployTools.Util; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using Azure.Core; -namespace AzureMcp.Areas.Quota.Services.Util; +namespace AzureMcp.Quota.Services.Util; public class PostgreSQLUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { diff --git a/src/Areas/Quota/Services/Util/Usage/SearchUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/SearchUsageChecker.cs similarity index 90% rename from src/Areas/Quota/Services/Util/Usage/SearchUsageChecker.cs rename to areas/quota/src/AzureMcp.Quota/Services/Util/Usage/SearchUsageChecker.cs index 74cd9059d..05a049f9b 100644 --- a/src/Areas/Quota/Services/Util/Usage/SearchUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/SearchUsageChecker.cs @@ -1,8 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using Azure.Core; using Azure.ResourceManager.Search; using Azure.ResourceManager.Search.Models; -namespace AzureMcp.Areas.Quota.Services.Util; +namespace AzureMcp.Quota.Services.Util; public class SearchUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { diff --git a/src/Areas/Quota/Services/Util/Usage/StorageUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/StorageUsageChecker.cs similarity index 90% rename from src/Areas/Quota/Services/Util/Usage/StorageUsageChecker.cs rename to areas/quota/src/AzureMcp.Quota/Services/Util/Usage/StorageUsageChecker.cs index 144321759..dff943aac 100644 --- a/src/Areas/Quota/Services/Util/Usage/StorageUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/StorageUsageChecker.cs @@ -1,9 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using Azure.Core; using Azure.ResourceManager; using Azure.ResourceManager.Storage; using Azure.ResourceManager.Storage.Models; -namespace AzureMcp.Areas.Quota.Services.Util; +namespace AzureMcp.Quota.Services.Util; public class StorageUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) { diff --git a/areas/quota/tests/AzureMcp.Quota.LiveTests/AzureMcp.Quota.LiveTests.csproj b/areas/quota/tests/AzureMcp.Quota.LiveTests/AzureMcp.Quota.LiveTests.csproj new file mode 100644 index 000000000..2ef019eb3 --- /dev/null +++ b/areas/quota/tests/AzureMcp.Quota.LiveTests/AzureMcp.Quota.LiveTests.csproj @@ -0,0 +1,17 @@ + + + true + Exe + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Areas/Quota/LiveTests/QuotaCommandTests.cs b/areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs similarity index 100% rename from tests/Areas/Quota/LiveTests/QuotaCommandTests.cs rename to areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs diff --git a/areas/quota/tests/AzureMcp.Quota.UnitTests/AzureMcp.Quota.UnitTests.csproj b/areas/quota/tests/AzureMcp.Quota.UnitTests/AzureMcp.Quota.UnitTests.csproj new file mode 100644 index 000000000..e46912569 --- /dev/null +++ b/areas/quota/tests/AzureMcp.Quota.UnitTests/AzureMcp.Quota.UnitTests.csproj @@ -0,0 +1,17 @@ + + + Exe + true + + + + + + + + + + + + + diff --git a/tests/Areas/Quota/UnitTests/RegionCheckCommandTests.cs b/areas/quota/tests/AzureMcp.Quota.UnitTests/RegionCheckCommandTests.cs similarity index 99% rename from tests/Areas/Quota/UnitTests/RegionCheckCommandTests.cs rename to areas/quota/tests/AzureMcp.Quota.UnitTests/RegionCheckCommandTests.cs index f979da55a..78cc3a7b7 100644 --- a/tests/Areas/Quota/UnitTests/RegionCheckCommandTests.cs +++ b/areas/quota/tests/AzureMcp.Quota.UnitTests/RegionCheckCommandTests.cs @@ -3,9 +3,9 @@ using System.CommandLine.Parsing; using System.Text.Json; -using AzureMcp.Areas.Quota.Commands; -using AzureMcp.Areas.Quota.Services; -using AzureMcp.Models.Command; +using AzureMcp.Core.Models.Command; +using AzureMcp.Quota.Commands; +using AzureMcp.Quota.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NSubstitute; diff --git a/tests/Areas/Quota/UnitTests/UsageCheckCommandTests.cs b/areas/quota/tests/AzureMcp.Quota.UnitTests/UsageCheckCommandTests.cs similarity index 98% rename from tests/Areas/Quota/UnitTests/UsageCheckCommandTests.cs rename to areas/quota/tests/AzureMcp.Quota.UnitTests/UsageCheckCommandTests.cs index dc8cd8d5f..0175145b4 100644 --- a/tests/Areas/Quota/UnitTests/UsageCheckCommandTests.cs +++ b/areas/quota/tests/AzureMcp.Quota.UnitTests/UsageCheckCommandTests.cs @@ -3,10 +3,10 @@ using System.CommandLine.Parsing; using System.Text.Json; -using AzureMcp.Areas.Quota.Commands; -using AzureMcp.Areas.Quota.Services; -using AzureMcp.Areas.Quota.Services.Util; -using AzureMcp.Models.Command; +using AzureMcp.Core.Models.Command; +using AzureMcp.Quota.Commands; +using AzureMcp.Quota.Services; +using AzureMcp.Quota.Services.Util; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NSubstitute; diff --git a/core/src/AzureMcp.Cli/Program.cs b/core/src/AzureMcp.Cli/Program.cs index 9225975f0..889dca873 100644 --- a/core/src/AzureMcp.Cli/Program.cs +++ b/core/src/AzureMcp.Cli/Program.cs @@ -83,6 +83,8 @@ private static IAreaSetup[] RegisterAreas() new AzureMcp.BicepSchema.BicepSchemaSetup(), new AzureMcp.AzureTerraformBestPractices.AzureTerraformBestPracticesSetup(), new AzureMcp.LoadTesting.LoadTestingSetup(), + new AzureMcp.Deploy.DeploySetup(), + new AzureMcp.Quota.QuotaSetup(), ]; } diff --git a/core/src/AzureMcp.Core/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs b/core/src/AzureMcp.Core/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs index ebe585d63..3dcfab780 100644 --- a/core/src/AzureMcp.Core/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs +++ b/core/src/AzureMcp.Core/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Diagnostics; +using System.Text.Json.Nodes; using AzureMcp.Core.Areas.Server; using AzureMcp.Core.Areas.Server.Models; using AzureMcp.Core.Areas.Server.Options; @@ -107,7 +108,7 @@ public async ValueTask CallToolHandler(RequestContext Date: Thu, 31 Jul 2025 17:11:08 +0800 Subject: [PATCH 16/56] fix test failure --- .../Server/Commands/ToolLoading/CommandFactoryToolLoader.cs | 2 +- .../Areas/Server/CommandFactoryHelpers.cs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/core/src/AzureMcp.Core/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs b/core/src/AzureMcp.Core/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs index 3dcfab780..1f5d57b3a 100644 --- a/core/src/AzureMcp.Core/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs +++ b/core/src/AzureMcp.Core/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs @@ -184,7 +184,7 @@ private static Tool GetTool(string fullName, IBaseCommand command) if (options.Count == 1 && options[0].Name == RawMcpToolInputOptionName) { var arguments = JsonNode.Parse(options[0].Description ?? "{}") as JsonObject ?? new JsonObject(); - tool.InputSchema = JsonSerializer.SerializeToElement(arguments, ServerJsonContext.Default.ToolInputSchema); + tool.InputSchema = JsonSerializer.SerializeToElement(arguments, ServerJsonContext.Default.JsonObject); return tool; } else diff --git a/core/tests/AzureMcp.Core.UnitTests/Areas/Server/CommandFactoryHelpers.cs b/core/tests/AzureMcp.Core.UnitTests/Areas/Server/CommandFactoryHelpers.cs index 7d43862be..26a0b1f12 100644 --- a/core/tests/AzureMcp.Core.UnitTests/Areas/Server/CommandFactoryHelpers.cs +++ b/core/tests/AzureMcp.Core.UnitTests/Areas/Server/CommandFactoryHelpers.cs @@ -6,6 +6,7 @@ using AzureMcp.Core.Areas.Subscription; using AzureMcp.Core.Commands; using AzureMcp.Core.Services.Telemetry; +using AzureMcp.Deploy; using AzureMcp.KeyVault; using AzureMcp.Storage; using Microsoft.Extensions.DependencyInjection; @@ -28,7 +29,8 @@ public static CommandFactory CreateCommandFactory(IServiceProvider? serviceProvi IAreaSetup[] areaSetups = [ new SubscriptionSetup(), new KeyVaultSetup(), - new StorageSetup() + new StorageSetup(), + new DeploySetup(), ]; var commandFactory = new CommandFactory(services, areaSetups, telemetryService, logger); From b7a04d8700ebf43da2aaaf63501b9f2e89413711 Mon Sep 17 00:00:00 2001 From: qianwens Date: Thu, 31 Jul 2025 20:38:56 +0800 Subject: [PATCH 17/56] Add sub command description to goup description --- .../src/AzureMcp.Deploy/Commands/GenerateMermaidChart.cs | 7 +++++-- areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateMermaidChart.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateMermaidChart.cs index 9c9960b18..e7ed3c843 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateMermaidChart.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateMermaidChart.cs @@ -39,8 +39,11 @@ public static string GenerateChart(string workspaceFolder, AppTopology appTopolo { var serviceName = new List { $"Name: {service.Name}" }; - var projectRelativePath = Path.GetRelativePath(workspaceFolder, string.IsNullOrWhiteSpace(service.Path) ? workspaceFolder : service.Path); - serviceName.Add($"Path: {projectRelativePath}"); + if (!string.IsNullOrWhiteSpace(workspaceFolder)) + { + var projectRelativePath = Path.GetRelativePath(workspaceFolder, string.IsNullOrWhiteSpace(service.Path) ? workspaceFolder : service.Path); + serviceName.Add($"Path: {projectRelativePath}"); + } serviceName.Add($"Language: {service.Language}"); serviceName.Add($"Port: {service.Port}"); diff --git a/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs b/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs index cf524e7e4..8d54e9de2 100644 --- a/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs +++ b/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs @@ -21,7 +21,12 @@ public void ConfigureServices(IServiceCollection services) public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactory) { - var deploy = new CommandGroup("deploy", "Deploy commands for deploying applications to Azure"); + var deploy = new CommandGroup("deploy", "Deploy commands for deploying applications to Azure, including sub commands: " + + "- plan-get: generate the deployment plan; " + + "- iac-rules-get: offers guidelines for creating Bicep/Terraform files to deploy applications on Azure; " + + "- azd-app-log-get: fetch logs from log analytics workspace for Container Apps, App Services, function apps that were deployed through azd; " + + "- cicd-pipeline-guidance-get: guidance to create a CI/CD pipeline which provision Azure resources and build and deploy applications to Azure; " + + "- architecture-diagram-generate: generates an azure service architecture diagram for the application based on the provided app topology; "); rootGroup.AddSubGroup(deploy); deploy.AddCommand("plan-get", new PlanGetCommand(loggerFactory.CreateLogger())); From 85a14e8d812bd8cd5c441a55a5e4b73a9d74831c Mon Sep 17 00:00:00 2001 From: qianwens Date: Fri, 1 Aug 2025 11:41:40 +0800 Subject: [PATCH 18/56] update description based on comment --- .../deploy/src/AzureMcp.Deploy/Commands/AzdAppLogGetCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/AzdAppLogGetCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/AzdAppLogGetCommand.cs index 3d5d114be..ca3c9b086 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/AzdAppLogGetCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/AzdAppLogGetCommand.cs @@ -25,7 +25,7 @@ public sealed class AzdAppLogGetCommand(ILogger logger) : S public override string Description => """ - This tool helps fetch logs from log analytics workspace for Container Apps, App Services, function apps that were deployed through azd. Invoke this tool directly after a successful `azd up` or when user prompts to check the app's status or provide errors in the deployed apps. + This tool fetches logs from the Log Analytics workspace for Container Apps, App Services, and Function Apps deployed using azd. Use it after a successful azd up to check app status or troubleshoot errors in deployed applications. """; protected override void RegisterOptions(Command command) From 985757f58cddbf89b2d13964ecd812715a3f7b31 Mon Sep 17 00:00:00 2001 From: xfz11 <81600993+xfz11@users.noreply.github.com> Date: Fri, 1 Aug 2025 07:14:31 +0000 Subject: [PATCH 19/56] fix live test (#13) * fix live test * update quota test * update --- .../DeployCommandTests.cs | 52 +++++++-------- areas/deploy/tests/test-resources-post.ps1 | 28 ++++++++ areas/deploy/tests/test-resources.bicep | 22 +++++++ .../Services/Util/AzureRegionChecker.cs | 58 +++++++---------- .../QuotaCommandTests.cs | 65 +++++++++++++++++-- areas/quota/tests/test-resources-post.ps1 | 28 ++++++++ areas/quota/tests/test-resources.bicep | 22 +++++++ 7 files changed, 209 insertions(+), 66 deletions(-) create mode 100644 areas/deploy/tests/test-resources-post.ps1 create mode 100644 areas/deploy/tests/test-resources.bicep create mode 100644 areas/quota/tests/test-resources-post.ps1 create mode 100644 areas/quota/tests/test-resources.bicep diff --git a/areas/deploy/tests/AzureMcp.Deploy.LiveTests/DeployCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.LiveTests/DeployCommandTests.cs index 0665a147e..25cdc98a3 100644 --- a/areas/deploy/tests/AzureMcp.Deploy.LiveTests/DeployCommandTests.cs +++ b/areas/deploy/tests/AzureMcp.Deploy.LiveTests/DeployCommandTests.cs @@ -27,7 +27,7 @@ public async Task Should_get_plan() { // act var result = await CallToolMessageAsync( - "azmcp-deploy-plan-get", + "azmcp_deploy_plan-get", new() { { "workspace-folder", "C:/" }, @@ -37,7 +37,7 @@ public async Task Should_get_plan() { "azd-iac-options", "bicep" } }); // assert - Assert.StartsWith(result, "Title:"); + Assert.StartsWith("# Azure Deployment Plan for django Project", result); } [Fact] @@ -53,7 +53,7 @@ public async Task Should_get_infrastructure_code_rules() // act var result = await CallToolMessageAsync( - "azmcp-deploy-iac-rules-get", + "azmcp_deploy_iac-rules-get", new() { { "deployment-tool", "azd" }, @@ -61,7 +61,7 @@ public async Task Should_get_infrastructure_code_rules() { "resource-types", "appservice, azurestorage" } }); - Assert.Contains("Deployment Tool: azd", result ?? String.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Deployment Tool azd rules", result ?? String.Empty, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -69,7 +69,7 @@ public async Task Should_get_infrastructure_rules_for_terraform() { // act var result = await CallToolMessageAsync( - "azmcp-deploy-iac-rules-get", + "azmcp_deploy_iac-rules-get", new() { { "deployment-tool", "azd" }, @@ -78,7 +78,7 @@ public async Task Should_get_infrastructure_rules_for_terraform() }); // assert - Assert.Contains("IaC Type: terraform. IaC Type rules:", result ?? String.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Contains("IaC Type: terraform rules", result ?? String.Empty, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -86,7 +86,7 @@ public async Task Should_generate_pipeline() { // act var result = await CallToolMessageAsync( - "azmcp-deploy-pipeline-generate", + "azmcp_deploy_cicd-pipeline-guidance-get", new() { { "subscription", _subscriptionId }, @@ -102,7 +102,7 @@ public async Task Should_generate_pipeline_with_github_details() { // act var result = await CallToolMessageAsync( - "azmcp-deploy-pipeline-generate", + "azmcp_deploy_cicd-pipeline-guidance-get", new() { { "subscription", _subscriptionId }, @@ -116,24 +116,24 @@ public async Task Should_generate_pipeline_with_github_details() Assert.StartsWith("Help the user to set up a CI/CD pipeline", result ?? String.Empty); } - - [Fact] - public async Task Should_get_azd_app_logs() - { - // act - var result = await CallToolMessageAsync( - "azmcp-deploy-azd-app-log-get", - new() - { - { "subscription", _subscriptionId }, - { "workspace-folder", "C:/Users/" }, - { "azd-env-name", "dotnet-demo" }, - { "limit", 10 } - }); - - // assert - Assert.StartsWith("App logs retrieved:", result); - } + // skip as this test need local files + // [Fact] + // public async Task Should_get_azd_app_logs() + // { + // // act + // var result = await CallToolMessageAsync( + // "azmcp_deploy_azd-app-log-get", + // new() + // { + // { "subscription", _subscriptionId }, + // { "workspace-folder", "C:/Users/" }, + // { "azd-env-name", "dotnet-demo" }, + // { "limit", 10 } + // }); + + // // assert + // Assert.StartsWith("App logs retrieved:", result); + // } private async Task CallToolMessageAsync(string command, Dictionary parameters) diff --git a/areas/deploy/tests/test-resources-post.ps1 b/areas/deploy/tests/test-resources-post.ps1 new file mode 100644 index 000000000..b02b0a155 --- /dev/null +++ b/areas/deploy/tests/test-resources-post.ps1 @@ -0,0 +1,28 @@ +param( + [string] $TenantId, + [string] $TestApplicationId, + [string] $ResourceGroupName, + [string] $BaseName, + [hashtable] $DeploymentOutputs +) + +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot/../../../eng/common/scripts/common.ps1" +. "$PSScriptRoot/../../../eng/scripts/helpers/TestResourcesHelpers.ps1" + +$testSettings = New-TestSettings @PSBoundParameters -OutputPath $PSScriptRoot + +# $testSettings contains: +# - TenantId +# - TenantName +# - SubscriptionId +# - SubscriptionName +# - ResourceGroupName +# - ResourceBaseName + +# $DeploymentOutputs keys are all UPPERCASE + +# Add your post deployment steps here +# For example, you might want to configure resources or run additional scripts. + diff --git a/areas/deploy/tests/test-resources.bicep b/areas/deploy/tests/test-resources.bicep new file mode 100644 index 000000000..943ea908c --- /dev/null +++ b/areas/deploy/tests/test-resources.bicep @@ -0,0 +1,22 @@ +// Live test runs require a resource file, so we use an empty one here. +targetScope = 'resourceGroup' + +@minLength(3) +@maxLength(24) +@description('The base resource name.') +param baseName string + +@description('The client OID to grant access to test resources.') +param testApplicationOid string = deployer().objectId + +var location string = resourceGroup().location +var tenantId string = subscription().tenantId + +// Add any additional resources and role assignments needed for live tests here. + + +// Outputs will be available in test-resources-post.ps1 +output location string = location + +// Their keys will be uppercase +// $DeploymentOutputs.LOCATION diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs index a96da23f5..f0b7f7ac3 100644 --- a/areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs @@ -96,17 +96,13 @@ public override async Task> GetAvailableRegionsAsync(string resourc .Select(location => location.Replace(" ", "").ToLowerInvariant()) .ToList() ?? new List(); - var availableRegions = new List(); - - foreach (var region in regions) + var tasks = regions.Select(async region => { try { - var quotas = subscription.GetModels(region); - - bool hasMatchingModel = false; + var quotas = subscription.GetModelsAsync(region); - foreach (CognitiveServicesModel modelElement in quotas) + await foreach (CognitiveServicesModel modelElement in quotas) { var nameMatch = string.IsNullOrEmpty(_modelName) || (modelElement.Model?.Name == _modelName); @@ -114,29 +110,24 @@ public override async Task> GetAvailableRegionsAsync(string resourc var versionMatch = string.IsNullOrEmpty(_apiVersion) || (modelElement.Model?.Version == _apiVersion); - var skuMatch = string.IsNullOrEmpty(_skuName) || (modelElement.Model?.Skus?.Any(sku => sku.Name == _skuName) ?? false); if (nameMatch && versionMatch && skuMatch) { - hasMatchingModel = true; - break; + return region; } } - - if (hasMatchingModel) - { - availableRegions.Add(region); - } } catch (Exception error) { - throw new Exception($"Error checking cognitive services models for region {region}: {error.Message}"); + Console.WriteLine($"Error checking cognitive services models for region {region}: {error.Message}"); } - } + return null; + }); - return availableRegions; + var results = await Task.WhenAll(tasks); + return results.Where(region => region != null).ToList()!; } } @@ -156,29 +147,28 @@ public override async Task> GetAvailableRegionsAsync(string resourc .Select(location => location.Replace(" ", "").ToLowerInvariant()) .ToList() ?? new List(); - var availableRegions = new List(); - - foreach (var region in regions) + var tasks = regions.Select(async region => { try { - Pageable result = subscription.ExecuteLocationBasedCapabilities(region); - foreach (var capability in result) + AsyncPageable result = subscription.ExecuteLocationBasedCapabilitiesAsync(region); + await foreach (var capability in result) { if (capability.SupportedServerEditions?.Any() == true) { - availableRegions.Add(region); - break; // No need to check further capabilities for this region + return region; } } } catch (Exception error) { - throw new Exception($"Error checking PostgreSQL capabilities for region {region}: {error.Message}"); + Console.WriteLine($"Error checking PostgreSQL capabilities for region {region}: {error.Message}"); } - } + return null; + }); - return availableRegions; + var results = await Task.WhenAll(tasks); + return results.Where(region => region != null).ToList()!; } } @@ -214,14 +204,14 @@ public static async Task>> GetAvailableRegionsFo string subscriptionId, CognitiveServiceProperties? cognitiveServiceProperties = null) { - var result = new Dictionary>(); - - foreach (var resourceType in resourceTypes) + var tasks = resourceTypes.Select(async resourceType => { var checker = RegionCheckerFactory.CreateRegionChecker(armClient, subscriptionId, resourceType, cognitiveServiceProperties); - result[resourceType] = await checker.GetAvailableRegionsAsync(resourceType); - } + var regions = await checker.GetAvailableRegionsAsync(resourceType); + return new KeyValuePair>(resourceType, regions); + }); - return result; + var results = await Task.WhenAll(tasks); + return results.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); } } diff --git a/areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs b/areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs index c1d5970fc..cd015efa1 100644 --- a/areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs +++ b/areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs @@ -23,12 +23,14 @@ public QuotaCommandTests(LiveTestFixture liveTestFixture, ITestOutputHelper outp [Trait("Category", "Live")] public async Task Should_check_azure_quota() { + // act + var resourceTypes = "Microsoft.CognitiveServices, Microsoft.Compute, Microsoft.Storage, Microsoft.App, Microsoft.Network, Microsoft.MachineLearningServices, Microsoft.DBforPostgreSQL, Microsoft.HDInsight, Microsoft.Search, Microsoft.ContainerInstance"; JsonElement? result = await CallToolAsync( - "azmcp-quota-usage-get", + "azmcp_quota_usage-get", new() { { "subscription", _subscriptionId }, { "region", "eastus" }, - { "resource-types", "Microsoft.App, Microsoft.Storage/storageAccounts" } + { "resource-types", resourceTypes } }); // assert var quotas = result.AssertProperty("usageInfo"); @@ -36,9 +38,34 @@ public async Task Should_check_azure_quota() var appQuotas = quotas.AssertProperty("Microsoft.App"); Assert.Equal(JsonValueKind.Array, appQuotas.ValueKind); Assert.NotEmpty(appQuotas.EnumerateArray()); - var storageQuotas = quotas.AssertProperty("Microsoft.Storage/storageAccounts"); + var storageQuotas = quotas.AssertProperty("Microsoft.Storage"); Assert.Equal(JsonValueKind.Array, storageQuotas.ValueKind); Assert.NotEmpty(storageQuotas.EnumerateArray()); + var cognitiveQuotas = quotas.AssertProperty("Microsoft.CognitiveServices"); + Assert.Equal(JsonValueKind.Array, cognitiveQuotas.ValueKind); + Assert.NotEmpty(cognitiveQuotas.EnumerateArray()); + var computeQuotas = quotas.AssertProperty("Microsoft.Compute"); + Assert.Equal(JsonValueKind.Array, computeQuotas.ValueKind); + Assert.NotEmpty(computeQuotas.EnumerateArray()); + var networkQuotas = quotas.AssertProperty("Microsoft.Network"); + Assert.Equal(JsonValueKind.Array, networkQuotas.ValueKind); + Assert.NotEmpty(networkQuotas.EnumerateArray()); + var mlQuotas = quotas.AssertProperty("Microsoft.MachineLearningServices"); + Assert.Equal(JsonValueKind.Array, mlQuotas.ValueKind); + Assert.NotEmpty(mlQuotas.EnumerateArray()); + var postgresqlQuotas = quotas.AssertProperty("Microsoft.DBforPostgreSQL"); + Assert.Equal(JsonValueKind.Array, postgresqlQuotas.ValueKind); + Assert.NotEmpty(postgresqlQuotas.EnumerateArray()); + var hdInsightQuotas = quotas.AssertProperty("Microsoft.HDInsight"); + Assert.Equal(JsonValueKind.Array, hdInsightQuotas.ValueKind); + Assert.NotEmpty(hdInsightQuotas.EnumerateArray()); + var searchQuotas = quotas.AssertProperty("Microsoft.Search"); + Assert.Equal(JsonValueKind.Array, searchQuotas.ValueKind); + Assert.NotEmpty(searchQuotas.EnumerateArray()); + var containerInstanceQuotas = quotas.AssertProperty("Microsoft.ContainerInstance"); + Assert.Equal(JsonValueKind.Array, containerInstanceQuotas.ValueKind); + Assert.NotEmpty(containerInstanceQuotas.EnumerateArray()); + } [Fact] @@ -47,17 +74,32 @@ public async Task Should_check_azure_regions() { // act var result = await CallToolAsync( - "azmcp-quota-available-region-list", + "azmcp_quota_available-region-list", new() { { "subscription", _subscriptionId }, - { "resource-types", "Microsoft.Web/sites, Microsoft.Storage/storageAccounts" }, + { "resource-types", "Microsoft.Web/sites, Microsoft.Storage/storageAccounts, microsoft.dbforpostgresql/flexibleservers" }, }); // assert var availableRegions = result.AssertProperty("availableRegions"); Assert.Equal(JsonValueKind.Array, availableRegions.ValueKind); Assert.NotEmpty(availableRegions.EnumerateArray()); + var actualRegions = availableRegions.EnumerateArray().Select(x => x.GetString() ?? string.Empty).ToHashSet(); + // only available for subscription 9e347dc4-e2fb-4892-b7c0-ca6f58eeed6d + // var expectedRegions = new HashSet + // { + // "southafricanorth","westus","australiaeast","brazilsouth","southeastasia", + // "centralus","japanwest","centralindia","uksouth","koreacentral","francecentral", + // "northeurope","australiacentral","uaenorth","swedencentral","switzerlandnorth", + // "northcentralus","ukwest","australiasoutheast","koreasouth","canadacentral", + // "westeurope","southindia","westcentralus","westus3","eastasia","japaneast", + // "jioindiawest","polandcentral","italynorth","israelcentral","spaincentral", + // "mexicocentral","newzealandnorth","indonesiacentral","malaysiawest","chilecentral", + // "eastus2euap","norwaywest","norwayeast","germanynorth","brazilsoutheast", + // "swedensouth","switzerlandwest" + // }; + // Assert.Equal(expectedRegions, actualRegions); } [Fact] @@ -66,7 +108,7 @@ public async Task Should_check_regions_with_cognitive_services() { // act var result = await CallToolAsync( - "azmcp-quota-available-region-list", + "azmcp_quota_available-region-list", new() { { "subscription", _subscriptionId }, @@ -79,5 +121,16 @@ public async Task Should_check_regions_with_cognitive_services() var availableRegions = result.AssertProperty("availableRegions"); Assert.Equal(JsonValueKind.Array, availableRegions.ValueKind); Assert.NotEmpty(availableRegions.EnumerateArray()); + var actualRegions = availableRegions.EnumerateArray().Select(x => x.GetString() ?? string.Empty).ToHashSet(); + + // only available for subscription 9e347dc4-e2fb-4892-b7c0-ca6f58eeed6d + // var expectedRegions = new HashSet + // { + // "australiaeast", "westus", "southcentralus", "eastus", "eastus2", + // "japaneast", "uksouth", "francecentral", "northcentralus", + // "swedencentral", "switzerlandnorth", "norwayeast", "westus3", + // "canadaeast", "southindia" + // }; + // Assert.Equal(expectedRegions, actualRegions); } } diff --git a/areas/quota/tests/test-resources-post.ps1 b/areas/quota/tests/test-resources-post.ps1 new file mode 100644 index 000000000..b02b0a155 --- /dev/null +++ b/areas/quota/tests/test-resources-post.ps1 @@ -0,0 +1,28 @@ +param( + [string] $TenantId, + [string] $TestApplicationId, + [string] $ResourceGroupName, + [string] $BaseName, + [hashtable] $DeploymentOutputs +) + +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot/../../../eng/common/scripts/common.ps1" +. "$PSScriptRoot/../../../eng/scripts/helpers/TestResourcesHelpers.ps1" + +$testSettings = New-TestSettings @PSBoundParameters -OutputPath $PSScriptRoot + +# $testSettings contains: +# - TenantId +# - TenantName +# - SubscriptionId +# - SubscriptionName +# - ResourceGroupName +# - ResourceBaseName + +# $DeploymentOutputs keys are all UPPERCASE + +# Add your post deployment steps here +# For example, you might want to configure resources or run additional scripts. + diff --git a/areas/quota/tests/test-resources.bicep b/areas/quota/tests/test-resources.bicep new file mode 100644 index 000000000..943ea908c --- /dev/null +++ b/areas/quota/tests/test-resources.bicep @@ -0,0 +1,22 @@ +// Live test runs require a resource file, so we use an empty one here. +targetScope = 'resourceGroup' + +@minLength(3) +@maxLength(24) +@description('The base resource name.') +param baseName string + +@description('The client OID to grant access to test resources.') +param testApplicationOid string = deployer().objectId + +var location string = resourceGroup().location +var tenantId string = subscription().tenantId + +// Add any additional resources and role assignments needed for live tests here. + + +// Outputs will be available in test-resources-post.ps1 +output location string = location + +// Their keys will be uppercase +// $DeploymentOutputs.LOCATION From 62b66fc3cd51604bd3c3bf0425830ce96aec8658 Mon Sep 17 00:00:00 2001 From: xfz11 <81600993+xfz11@users.noreply.github.com> Date: Fri, 1 Aug 2025 07:35:20 +0000 Subject: [PATCH 20/56] update cspell (#14) --- .vscode/cspell.json | 144 +++++++++++------- .../QuotaCommandTests.cs | 4 +- 2 files changed, 90 insertions(+), 58 deletions(-) diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 9aec007fe..d9f111bd5 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -178,9 +178,12 @@ "Apim", "appconfig", "appservice", + "australiacentral", + "australiaeast", + "australiasoutheast", + "Autorenewable", "azapi", "azcli", - "Autorenewable", "azext", "azmcp", "azqr", @@ -191,6 +194,7 @@ "azureaiservices", "azureapplicationinsights", "azureappservice", + "azurebestpractices", "azureblob", "azurebotservice", "azurecacheforredis", @@ -202,17 +206,13 @@ "azuredatabaseforpostgresql", "azuredocs", "azurefunctions", + "azureisv", "azurekeyvault", "azuremcp", "azureopenai", "azureprivateendpoint", - "azuresdk", - "azurebestpractices", - "azurefunctions", - "azureisv", - "azuremcp", - "azureresources", "azureresourcegroups", + "azureresources", "azurerm", "azuresdk", "azureservicebus", @@ -225,73 +225,86 @@ "azuretools", "azurevirtualnetwork", "azurewebpubsub", - "azurefunctions", "backendservice", - "azuresdk", - "codegen", - "codeium", - "cslschema", "bdylan", "bestpractices", "bicepschema", + "brazilsouth", + "brazilsoutheast", "breathability", + "Byol", + "canadacentral", + "canadaeast", + "centralindia", + "centralus", + "chilecentral", "cicd", + "codegen", + "codeium", "codeium", - "Byol", - "codesign", "CODEOWNERS", "codesign", + "codesign", "cognitiveservices", "containerapp", "containerapps", "copilotmd", - "cslschema", "Cosell", + "cslschema", + "cslschema", "cvzf", - "dataplane", "datalake", + "dataplane", "datasource", "datasources", "dbforpostgresql", - "Distributedtask", "DEBUGTELEMETRY", + "discoverability", + "Distributedtask", "dotenv", "drawcord", - "discoverability", + "eastasia", + "eastus2euap", "enumerables", "eslintcache", + "esrp", + "ESRPRELPACMANTEST", "eventgrid", "exfiltration", + "exfiltration", + "facetable", + "filefilters", "filefilters", "fnames", + "francecentral", "frontendservice", + "functionapp", + "germanynorth", + "gethealth", "gethealth", "grpcio", "Gsaascend", "Gsamas", "healthmodels", - "jspm", - "kcsb", - "keyspace", - "Kusto", - "loadtest", - "loadtesting", - "loadtests", - "esrp", - "ESRPRELPACMANTEST", - "exfiltration", - "facetable", - "filefilters", - "functionapp", - "gethealth", "healthmodels", "hnsw", "idempotency", "idtyp", + "indonesiacentral", + "israelcentral", + "italynorth", + "japaneast", + "japanwest", + "jioindiawest", "jsonencode", + "jspm", + "kcsb", "kcsb", "keyspace", "keyvault", + "koreacentral", + "koreasouth", + "Kusto", "Kusto", "Kusto", "kvps", @@ -309,34 +322,43 @@ "MACOS", "MACPOOL", "MACVMIMAGE", + "malaysiawest", + "mexicocentral", + "Microbundle", "midsole", "monitoredresources", "msal", + "MSRP", "myaccount", + "myapp", + "mycluster", "myfilesystem", + "mygroup", "mysvc", - "mycluster", - "Microbundle", - "MACOS", - "MACPOOL", - "MACVMIMAGE", - "MSRP", + "myworkbook", "Newtonsoft", + "newzealandnorth", + "norequired", "northcentralus", + "northeurope", + "norwayeast", + "norwaywest", "Npgsql", - "norequired", "npmjs", "nuxt", "odata", "oidc", "onboarded", "openai", + "operationalinsights", "packability", "pageable", "payg", "paygo", "pgrep", "pids", + "piechart", + "polandcentral", "portalsettings", "predeploy", "privatepreview", @@ -353,47 +375,57 @@ "setparam", "siteextensions", "skillset", - "staticwebapp", - "syslib", "skillsets", + "southafricanorth", + "southcentralus", + "southeastasia", + "southindia", + "spaincentral", + "staticwebapp", "staticwebapps", "submode", + "swedencentral", + "swedensouth", + "switzerlandnorth", + "switzerlandwest", + "syslib", "syslib", - "testresource", - "testrun", - "testsettings", - "tfvars", - "testfilesystem", "test_test_pmc2pc1.vmsr_uat_beta", + "testfilesystem", + "testresource", "testresource", "testrun", + "testrun", + "testsettings", "testsettings", + "tfvars", + "timechart", "timespan", "toolsets", + "uaenorth", + "uksouth", + "ukwest", "Upns", "vectorizable", "vectorizer", "vectorizers", "virtualmachines", - "vuepress", "Vnet", - "vsts", "vscodeignore", "vsmarketplace", + "vsts", + "vuepress", + "westcentralus", + "westeurope", "westus", "westus2", - "wscript", + "westus3", "WINDOWSOS", "WINDOWSPOOL", "WINDOWSVMIMAGE", "winget", - "xvfb", + "wscript", "Xunit", - "operationalinsights", - "piechart", - "timechart", - "myapp", - "mygroup", - "myworkbook" + "xvfb" ] } diff --git a/areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs b/areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs index cd015efa1..28aeb5df5 100644 --- a/areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs +++ b/areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs @@ -65,7 +65,7 @@ public async Task Should_check_azure_quota() var containerInstanceQuotas = quotas.AssertProperty("Microsoft.ContainerInstance"); Assert.Equal(JsonValueKind.Array, containerInstanceQuotas.ValueKind); Assert.NotEmpty(containerInstanceQuotas.EnumerateArray()); - + } [Fact] @@ -78,7 +78,7 @@ public async Task Should_check_azure_regions() new() { { "subscription", _subscriptionId }, - { "resource-types", "Microsoft.Web/sites, Microsoft.Storage/storageAccounts, microsoft.dbforpostgresql/flexibleservers" }, + { "resource-types", "Microsoft.Web/sites, Microsoft.Storage/storageAccounts, microsoft.dbforpostgresql/flexibleServers" }, }); // assert From c1e5f7cff17058bd5900a6654c91015c174e8d48 Mon Sep 17 00:00:00 2001 From: xfz11 <81600993+xfz11@users.noreply.github.com> Date: Mon, 4 Aug 2025 07:09:52 +0000 Subject: [PATCH 21/56] force to save plan to file (#15) --- areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs | 2 +- .../src/AzureMcp.Deploy/Templates/Plan/deployment-plan-base.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs b/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs index 8d54e9de2..d3ecb2cba 100644 --- a/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs +++ b/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs @@ -22,7 +22,7 @@ public void ConfigureServices(IServiceCollection services) public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactory) { var deploy = new CommandGroup("deploy", "Deploy commands for deploying applications to Azure, including sub commands: " - + "- plan-get: generate the deployment plan; " + + "- plan-get: entry point to help the agent deploy a project to Azure. Agent should read its output and generate a deploy plan in '.azure/plan.copilotmd' for execution steps, recommended azure services based on the information agent detected from project. Before calling this tool, please scan this workspace to detect the services to deploy and their dependent services; " + "- iac-rules-get: offers guidelines for creating Bicep/Terraform files to deploy applications on Azure; " + "- azd-app-log-get: fetch logs from log analytics workspace for Container Apps, App Services, function apps that were deployed through azd; " + "- cicd-pipeline-guidance-get: guidance to create a CI/CD pipeline which provision Azure resources and build and deploy applications to Azure; " diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/deployment-plan-base.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/deployment-plan-base.md index dc7439645..785b3a5b1 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/deployment-plan-base.md +++ b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/deployment-plan-base.md @@ -1,5 +1,7 @@ # {{Title}} +{Agent should fill in and polish the markdown template below to generate a deployment plan for the project. Then save it to '.azure/plan.copilotmd' file. Don't add cost estimation! Don't add extra validation steps unless it is required! Don't change the tool name!} + ## **Goal** Based on the project to provide a plan to deploy the project to {{AzureComputeHost}} using {{ProvisioningTool}}. From b6780fdc536540274993a3f16865a07fde73a045 Mon Sep 17 00:00:00 2001 From: qianwens Date: Tue, 5 Aug 2025 14:47:13 +0800 Subject: [PATCH 22/56] update plan command description --- areas/deploy/src/AzureMcp.Deploy/Commands/PlanGetCommand.cs | 2 +- areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs | 2 +- core/src/AzureMcp.Core/Commands/CommandExtensions.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/PlanGetCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/PlanGetCommand.cs index 152675ca2..ab4d9bcf4 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/PlanGetCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/PlanGetCommand.cs @@ -25,7 +25,7 @@ public sealed class PlanGetCommand(ILogger logger) public override string Description => """ - Entry point to help the agent deploy a service to the cloud. Agent should read its output and generate a deploy plan in '.azure/plan.copilotmd' for execution steps, recommended azure services based on the information agent detected from project. Before calling this tool, please scan this workspace to detect the services to deploy and their dependent services. + Generates a deployment plan to construct the infrastructure and deploy the application on Azure. Agent should read its output and generate a deploy plan in '.azure/plan.copilotmd' for execution steps, recommended azure services based on the information agent detected from project. Before calling this tool, please scan this workspace to detect the services to deploy and their dependent services. """; public override string Title => CommandTitle; diff --git a/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs b/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs index d3ecb2cba..e738e07d9 100644 --- a/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs +++ b/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs @@ -22,7 +22,7 @@ public void ConfigureServices(IServiceCollection services) public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactory) { var deploy = new CommandGroup("deploy", "Deploy commands for deploying applications to Azure, including sub commands: " - + "- plan-get: entry point to help the agent deploy a project to Azure. Agent should read its output and generate a deploy plan in '.azure/plan.copilotmd' for execution steps, recommended azure services based on the information agent detected from project. Before calling this tool, please scan this workspace to detect the services to deploy and their dependent services; " + + "- plan-get: generates a deployment plan to construct the infrastructure and deploy the application on Azure. Agent should read its output and generate a deploy plan in '.azure/plan.copilotmd' for execution steps, recommended azure services based on the information agent detected from project. Before calling this tool, please scan this workspace to detect the services to deploy and their dependent services; " + "- iac-rules-get: offers guidelines for creating Bicep/Terraform files to deploy applications on Azure; " + "- azd-app-log-get: fetch logs from log analytics workspace for Container Apps, App Services, function apps that were deployed through azd; " + "- cicd-pipeline-guidance-get: guidance to create a CI/CD pipeline which provision Azure resources and build and deploy applications to Azure; " diff --git a/core/src/AzureMcp.Core/Commands/CommandExtensions.cs b/core/src/AzureMcp.Core/Commands/CommandExtensions.cs index 417dca829..177262714 100644 --- a/core/src/AzureMcp.Core/Commands/CommandExtensions.cs +++ b/core/src/AzureMcp.Core/Commands/CommandExtensions.cs @@ -95,7 +95,7 @@ public static ParseResult ParseFromRawMcpToolInput(this Command command, IReadOn return command.Parse(args.ToArray()); } - + /// /// Determines if an option expects a collection type that should be treated as an array /// From 0215c924be0471fa2d6d08aee74e5f890c75c8ef Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Tue, 5 Aug 2025 21:09:33 -0700 Subject: [PATCH 23/56] Add PR guidance document --- docs/PR-626-Final-Recommendations.md | 583 +++++++++++++++++++++++++++ 1 file changed, 583 insertions(+) create mode 100644 docs/PR-626-Final-Recommendations.md diff --git a/docs/PR-626-Final-Recommendations.md b/docs/PR-626-Final-Recommendations.md new file mode 100644 index 000000000..ef9d9b9ed --- /dev/null +++ b/docs/PR-626-Final-Recommendations.md @@ -0,0 +1,583 @@ +# PR #626 Final Recommendations - Deploy and Quota Commands + +## Executive Summary + +This document consolidates all analysis, feedback, and recommendations for PR #626 which introduces deployment and quota management commands to Azure MCP Server. Based on architectural review, standards compliance analysis, and stakeholder feedback, this document provides the definitive refactoring plan. + +## Table of Contents + +1. [Current State Analysis](#current-state-analysis) +2. [Final Architecture Recommendations](#final-architecture-recommendations) +3. [Command Structure Reorganization](#command-structure-reorganization) +4. [Integration with Existing Commands](#integration-with-existing-commands) +5. [Implementation Action Plan](#implementation-action-plan) +6. [Test Scenarios](#test-scenarios) +7. [Validation Criteria](#validation-criteria) + +## Current State Analysis + +### PR Overview +- **Files Added**: 105 files with 6,521 additions and 44 deletions +- **New Areas**: `deploy` and `quota` command areas +- **Current Commands**: 7 commands with hyphenated naming and flat registration + +### Standards Violations Identified +1. **Command Registration**: Uses flat `AddCommand()` instead of hierarchical `CommandGroup` pattern +2. **Command Naming**: Hyphenated names (`plan-get`, `iac-rules-get`) violate ` ` pattern +3. **Architecture**: Overlaps with existing `AzCommand` and `AzdCommand` in `areas/extension/` + +### Existing Extension Commands +The codebase contains: +- `areas/extension/src/AzureMcp.Extension/Commands/AzCommand.cs` - Full Azure CLI execution +- `areas/extension/src/AzureMcp.Extension/Commands/AzdCommand.cs` - Full AZD execution + +## Final Architecture Recommendations + +### Command Groups + +Organize tools into these command groups: + +1. **`quota`** - Resource quota checking and usage analysis +2. **`deploy azd`** - AZD-specific deployment tools +3. **`deploy az`** - Azure CLI-specific deployment tools +4. **`deploy diagrams`** - Architecture diagram generation + +### Command Structure Changes + +**From Current**: +```bash +azmcp deploy plan-get +azmcp deploy iac-rules-get +azmcp deploy azd-app-log-get +azmcp deploy cicd-pipeline-guidance-get +azmcp deploy architecture-diagram-generate +azmcp quota usage-get +azmcp quota available-region-list +``` + +**To Target**: +```bash +azmcp quota usage check +azmcp quota region availability list +azmcp deploy app logs get +azmcp deploy infrastructure rules get +azmcp deploy pipeline guidance get +azmcp deploy plan get +azmcp deploy architecture diagram generate +``` + +### Integration Strategy + +Integration with existing commands: +- **AZD Operations**: Use existing `azmcp extension azd` internally +- **Azure CLI Operations**: Use existing `azmcp extension az` internally +- **Value-Added Services**: PR commands provide structured guidance on top of base CLI + +## Command Structure Reorganization + +### Deploy Area Refactoring + +**File**: `areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs` + +**Target Structure**: +```csharp +public static void RegisterCommands(CommandGroup deploy) +{ + // Application-specific commands + var appGroup = new CommandGroup("app", "Application-specific deployment tools"); + appGroup.AddCommand("logs", new LogsGetCommand(...)); // app logs get + + // Infrastructure as Code commands + var infrastructureGroup = new CommandGroup("infrastructure", "Infrastructure as Code operations"); + infrastructureGroup.AddCommand("rules", new RulesGetCommand(...)); // infrastructure rules get + + // CI/CD Pipeline commands + var pipelineGroup = new CommandGroup("pipeline", "CI/CD pipeline operations"); + pipelineGroup.AddCommand("guidance", new GuidanceGetCommand(...)); // pipeline guidance get + + // Deployment planning commands + var planGroup = new CommandGroup("plan", "Deployment planning operations"); + planGroup.AddCommand("get", new GetCommand(...)); // plan get + + // Architecture diagram commands + var architectureGroup = new CommandGroup("architecture", "Architecture diagram operations"); + architectureGroup.AddCommand("diagram", new DiagramGenerateCommand(...)); // architecture diagram generate + + deploy.AddCommandGroup(appGroup); + deploy.AddCommandGroup(infrastructureGroup); + deploy.AddCommandGroup(pipelineGroup); + deploy.AddCommandGroup(planGroup); + deploy.AddCommandGroup(architectureGroup); +} +``` + +### Quota Area Refactoring + +**File**: `areas/quota/src/AzureMcp.Quota/QuotaSetup.cs` + +**Target Structure**: +```csharp +public static void RegisterCommands(CommandGroup quota) +{ + // Resource usage and quota operations + var usageGroup = new CommandGroup("usage", "Resource usage and quota operations"); + usageGroup.AddCommand("check", new CheckCommand(...)); // usage check + + // Region availability operations + var regionGroup = new CommandGroup("region", "Region availability operations"); + regionGroup.AddCommand("availability", new AvailabilityListCommand(...)); // region availability list + + quota.AddCommandGroup(usageGroup); + quota.AddCommandGroup(regionGroup); +} +``` + +### Command Name Property Updates + +**Changes Required**: +1. `PlanGetCommand.Name` → `"get"` (was `"plan-get"`) +2. `IaCRulesGetCommand.Name` → `"rules"` (was `"iac-rules-get"`) +3. `AzdAppLogGetCommand.Name` → `"logs"` (was `"azd-app-log-get"`) +4. `PipelineGenerateCommand.Name` → `"guidance"` (was `"cicd-pipeline-guidance-get"`) +5. `GenerateArchitectureDiagramCommand.Name` → `"diagram"` (was `"architecture-diagram-generate"`) +6. `UsageCheckCommand.Name` → `"check"` (was `"usage-get"`) +7. `RegionCheckCommand.Name` → `"availability"` (was `"available-region-list"`) + +## File and Folder Reorganization + +### Deploy Area File Structure Changes + +#### Current Structure: +``` +areas/deploy/src/AzureMcp.Deploy/ +├── Commands/ +│ ├── AzdAppLogGetCommand.cs +│ ├── GenerateArchitectureDiagramCommand.cs +│ ├── IaCRulesGetCommand.cs +│ ├── PipelineGenerateCommand.cs +│ └── PlanGetCommand.cs +├── Options/ +│ ├── AzdAppLogOptions.cs +│ ├── GenerateArchitectureDiagramOptions.cs +│ ├── IaCRulesOptions.cs +│ ├── PipelineGenerateOptions.cs +│ └── PlanGetOptions.cs +└── Services/ + └── [various service files] +``` + +#### Target Structure (Hierarchical Organization): +``` +areas/deploy/src/AzureMcp.Deploy/ +├── Commands/ +│ ├── App/ +│ │ └── LogsGetCommand.cs (renamed from AzdAppLogGetCommand.cs) +│ ├── Infrastructure/ +│ │ └── RulesGetCommand.cs (renamed from IaCRulesGetCommand.cs) +│ ├── Pipeline/ +│ │ └── GuidanceGetCommand.cs (renamed from PipelineGenerateCommand.cs) +│ ├── Plan/ +│ │ └── GetCommand.cs (renamed from PlanGetCommand.cs) +│ └── Architecture/ +│ └── DiagramGenerateCommand.cs (renamed from GenerateArchitectureDiagramCommand.cs) +├── Options/ +│ ├── App/ +│ │ └── LogsGetOptions.cs (renamed from AzdAppLogOptions.cs) +│ ├── Infrastructure/ +│ │ └── RulesGetOptions.cs (renamed from IaCRulesOptions.cs) +│ ├── Pipeline/ +│ │ └── GuidanceGetOptions.cs (renamed from PipelineGenerateOptions.cs) +│ ├── Plan/ +│ │ └── GetOptions.cs (renamed from PlanGetOptions.cs) +│ └── Architecture/ +│ └── DiagramGenerateOptions.cs (renamed from GenerateArchitectureDiagramOptions.cs) +├── Templates/ (new directory) +│ ├── InfrastructureRulesTemplate.md +│ ├── PipelineGuidanceTemplate.md +│ └── DeploymentPlanTemplate.md +└── Services/ + ├── ITemplateService.cs (new interface) + ├── TemplateService.cs (new implementation) + └── [existing service files] +``` + +### Quota Area File Structure Changes + +#### Current Structure: +``` +areas/quota/src/AzureMcp.Quota/ +├── Commands/ +│ ├── RegionCheckCommand.cs +│ └── UsageCheckCommand.cs +├── Options/ +│ ├── RegionCheckOptions.cs +│ └── UsageCheckOptions.cs +└── Services/ + └── [various service files] +``` + +#### Target Structure (Hierarchical Organization): +``` +areas/quota/src/AzureMcp.Quota/ +├── Commands/ +│ ├── Usage/ +│ │ └── CheckCommand.cs (renamed from UsageCheckCommand.cs) +│ └── Region/ +│ └── AvailabilityListCommand.cs (renamed from RegionCheckCommand.cs) +├── Options/ +│ ├── Usage/ +│ │ └── CheckOptions.cs (renamed from UsageCheckOptions.cs) +│ └── Region/ +│ └── AvailabilityListOptions.cs (renamed from RegionCheckOptions.cs) +└── Services/ + └── [existing service files] +``` + +### Detailed File Rename Mapping + +#### Deploy Area Command Files: +| Current File | New File | New Class Name | Purpose | +|--------------|----------|----------------|---------| +| `Commands/AzdAppLogGetCommand.cs` | `Commands/App/LogsGetCommand.cs` | `LogsGetCommand` | Get logs from AZD-deployed applications | +| `Commands/IaCRulesGetCommand.cs` | `Commands/Infrastructure/RulesGetCommand.cs` | `RulesGetCommand` | Get Infrastructure as Code rules and guidelines | +| `Commands/PipelineGenerateCommand.cs` | `Commands/Pipeline/GuidanceGetCommand.cs` | `GuidanceGetCommand` | Get CI/CD pipeline guidance and configuration | +| `Commands/PlanGetCommand.cs` | `Commands/Plan/GetCommand.cs` | `GetCommand` | Generate Azure deployment plans | +| `Commands/GenerateArchitectureDiagramCommand.cs` | `Commands/Architecture/DiagramGenerateCommand.cs` | `DiagramGenerateCommand` | Generate Azure architecture diagrams | + +#### Deploy Area Option Files: +| Current File | New File | New Class Name | Purpose | +|--------------|----------|----------------|---------| +| `Options/AzdAppLogOptions.cs` | `Options/App/LogsGetOptions.cs` | `LogsGetOptions` | Options for app log retrieval | +| `Options/IaCRulesOptions.cs` | `Options/Infrastructure/RulesGetOptions.cs` | `RulesGetOptions` | Options for IaC rules retrieval | +| `Options/PipelineGenerateOptions.cs` | `Options/Pipeline/GuidanceGetOptions.cs` | `GuidanceGetOptions` | Options for pipeline guidance | +| `Options/PlanGetOptions.cs` | `Options/Plan/GetOptions.cs` | `GetOptions` | Options for deployment planning | +| `Options/GenerateArchitectureDiagramOptions.cs` | `Options/Architecture/DiagramGenerateOptions.cs` | `DiagramGenerateOptions` | Options for diagram generation | + +#### Quota Area Command Files: +| Current File | New File | New Class Name | Purpose | +|--------------|----------|----------------|---------| +| `Commands/UsageCheckCommand.cs` | `Commands/Usage/CheckCommand.cs` | `CheckCommand` | Check Azure resource usage and quotas | +| `Commands/RegionCheckCommand.cs` | `Commands/Region/AvailabilityListCommand.cs` | `AvailabilityListCommand` | List available regions for resource types | + +#### Quota Area Option Files: +| Current File | New File | New Class Name | Purpose | +|--------------|----------|----------------|---------| +| `Options/UsageCheckOptions.cs` | `Options/Usage/CheckOptions.cs` | `CheckOptions` | Options for usage checking | +| `Options/RegionCheckOptions.cs` | `Options/Region/AvailabilityListOptions.cs` | `AvailabilityListOptions` | Options for region availability listing | + +### Test File Updates Required + +#### Deploy Area Test Files: +``` +areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/ +├── App/ +│ └── LogsGetCommandTests.cs (update from AzdAppLogGetCommandTests.cs) +├── Infrastructure/ +│ └── RulesGetCommandTests.cs (update from IaCRulesGetCommandTests.cs) +├── Pipeline/ +│ └── GuidanceGetCommandTests.cs (update from PipelineGenerateCommandTests.cs) +├── Plan/ +│ └── GetCommandTests.cs (update from PlanGetCommandTests.cs) +└── Architecture/ + └── DiagramGenerateCommandTests.cs (update from GenerateArchitectureDiagramCommandTests.cs) +``` + +#### Quota Area Test Files: +``` +areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/ +├── Usage/ +│ └── CheckCommandTests.cs (update from UsageCheckCommandTests.cs) +└── Region/ + └── AvailabilityListCommandTests.cs (update from RegionCheckCommandTests.cs) +``` + +### Namespace Updates Required + +#### Deploy Area Namespaces: +- `AzureMcp.Deploy.Commands.App` for application-specific commands (logs) +- `AzureMcp.Deploy.Commands.Infrastructure` for Infrastructure as Code commands +- `AzureMcp.Deploy.Commands.Pipeline` for CI/CD pipeline commands +- `AzureMcp.Deploy.Commands.Plan` for deployment planning commands +- `AzureMcp.Deploy.Commands.Architecture` for architecture diagram commands +- `AzureMcp.Deploy.Options.App` for application command options +- `AzureMcp.Deploy.Options.Infrastructure` for infrastructure command options +- `AzureMcp.Deploy.Options.Pipeline` for pipeline command options +- `AzureMcp.Deploy.Options.Plan` for planning command options +- `AzureMcp.Deploy.Options.Architecture` for architecture command options +- `AzureMcp.Deploy.Services` for template and other services + +#### Quota Area Namespaces: +- `AzureMcp.Quota.Commands.Usage` for usage-related commands +- `AzureMcp.Quota.Commands.Region` for region-related commands +- `AzureMcp.Quota.Options.Usage` for usage command options +- `AzureMcp.Quota.Options.Region` for region command options + +### Project File Updates + +#### Deploy Area Project File: +```xml + + + + + + + + + +``` + +#### Quota Area Project File: +```xml + + + + +``` + +### Registration File Updates + +#### DeploySetup.cs: +- Update `using` statements for new namespaces +- Update command registration to use `CommandGroup` hierarchy +- Register new `ITemplateService` and extension services + +#### QuotaSetup.cs: +- Update `using` statements for new namespaces +- Update command registration to use `CommandGroup` hierarchy +- Register extension services + +## Integration with Existing Commands + +### Internal Service Integration + +**Add Extension Dependencies**: +```xml + + +``` + +**Service Registration**: +```csharp +// In area setup ConfigureServices methods +services.AddTransient(); +services.AddTransient(); +``` + +### Command Implementation Updates + +**Example: AzdAppLogGetCommand using existing AzdCommand**: +```csharp +public sealed class AzdAppLogGetCommand( + ILogger logger, + IAzdService azdService) : SubscriptionCommand() +{ + public override string Name => "logs"; + + protected override async Task ExecuteAsync(AzdAppLogOptions options, CancellationToken cancellationToken) + { + // Use existing AZD service to get environment info + var envResult = await azdService.ExecuteAsync("env list", options.WorkspaceFolder); + + // Use existing AZD service to get logs + var logsResult = await azdService.ExecuteAsync($"monitor logs --environment {options.AzdEnvName}", options.WorkspaceFolder); + + // Add value by filtering and formatting logs for specific app types + var filteredLogs = FilterLogsForAppTypes(logsResult.Output); + + return McpResult.Success(filteredLogs); + } +} +``` + +## Prompt Template Consolidation + +### Template System Enhancement + +**Objective**: Replace dynamic prompt construction with embedded markdown templates. + +**Implementation**: +1. Create `areas/deploy/src/AzureMcp.Deploy/Templates/` directory +2. Extract prompts to markdown files: + - `AzdRulesTemplate.md` + - `PipelineGuidanceTemplate.md` + - `DeploymentPlanTemplate.md` +3. Create injectable `ITemplateService` interface +4. Add embedded resources to project file + +**Template Service Interface**: +```csharp +public interface ITemplateService +{ + Task GetTemplateAsync(string templateName, object parameters = null); +} +``` + +### Deployment Planning Separation + +**Split PlanGetCommand Responsibilities**: +- **Keep**: Project analysis and service recommendations +- **Add**: Next steps with specific tool commands +- **Remove**: Direct file generation (.azure/plan.copilotmd) + +**Next Steps Response**: +```csharp +public class PlanAnalysisResult +{ + public string[] RecommendedServices { get; set; } + public string[] NextStepCommands { get; set; } // Specific azmcp commands to run + public string DeploymentStrategy { get; set; } + public string[] RequiredTools { get; set; } +} +``` + +## Implementation Action Plan + +### Phase 1: Priority 0 (Must Complete First) + +#### 1.1 Command Registration Refactoring +- **Priority**: P0 +- **Files**: `DeploySetup.cs`, `QuotaSetup.cs` +- **Action**: Replace flat registration with `CommandGroup` hierarchy +- **Validation**: Commands accessible via new structure + +#### 1.2 Command Name Updates +- **Priority**: P0 +- **Files**: All command class files +- **Action**: Update `Name` properties to single verbs +- **Validation**: Unit tests pass with new names + +#### 1.3 Build Verification +- **Priority**: P0 +- **Action**: `dotnet build AzureMcp.sln` +- **Expected**: Zero compilation errors + +### Phase 2: Integration and Enhancement + +#### 2.1 Extension Service Integration +- **Priority**: P1 +- **Action**: Add project references and service injection +- **Validation**: Commands use existing Az/Azd services internally + +#### 2.2 Test Updates +- **Priority**: P1 +- **Action**: Update unit and live tests for new structure +- **Validation**: All tests pass + +### Phase 3: Optional Enhancements + +#### 3.1 Template System +- **Priority**: P2 +- **Action**: Extract prompts to embedded resources +- **Validation**: Template loading works correctly + +#### 3.2 Documentation +- **Priority**: P2 +- **Action**: Update `azmcp-commands.md` and `new-command.md` +- **Validation**: Documentation reflects new structure + +## Test Scenarios + +### Test Cases + +1. **Command Registration** + ```bash + azmcp deploy app logs --help + azmcp quota usage check --help + ``` + +2. **Extension Integration** + ```bash + azmcp deploy app logs --workspace-folder ./myapp --azd-env-name dev + ``` + +3. **Hierarchical Structure** + ```bash + # Should work + azmcp deploy app --help + azmcp quota usage --help + + # Should not work (old format) + azmcp deploy azd-app-log-get + ``` + +### Deployment Workflow Tests + +4. **End-to-End Planning** + - User: "Plan deployment for my .NET app" + - Expected: Returns structured plan with next step commands + +5. **Quota Checking** + - User: "Check compute quota in East US" + - Expected: Returns structured quota information + +6. **Architecture Diagrams** + - User: "Generate diagram for my microservices" + - Expected: Returns Mermaid diagram + +### Error Handling Tests + +7. **Invalid Commands** + - Command: `azmcp deploy plan-get` (old format) + - Expected: Command not found error + +8. **Missing Dependencies** + - Command: `azmcp deploy azd logs` without AZD installed + - Expected: Clear error message with installation instructions + +## Validation Criteria + +### Build and Test Requirements + +- [ ] `dotnet build AzureMcp.sln` succeeds with zero errors +- [ ] All unit tests pass +- [ ] Live tests pass (when Azure credentials available) +- [ ] CLI help commands work for all new structures + +### Command Structure Compliance + +- [ ] All commands follow ` ` pattern +- [ ] No hyphenated command names +- [ ] Hierarchical `CommandGroup` registration used +- [ ] Command names are single verbs (`get`, `list`, `generate`, etc.) + +### Integration Requirements + +- [ ] Deploy commands use existing Extension services internally +- [ ] No duplication of Az/Azd CLI functionality +- [ ] Value-added services provide structured guidance +- [ ] Clear differentiation between guided vs direct CLI access + +### Documentation Standards + +- [ ] All commands documented in `azmcp-commands.md` +- [ ] Examples show new command structure +- [ ] Migration notes for changed command names +- [ ] Integration patterns documented in `new-command.md` + +## Post-Implementation Considerations + +### Future Architecture Evolution + +1. **AZD MCP Server Migration**: When Azure Developer CLI creates their own MCP server, evaluate migrating AZD-specific tools +2. **Template System Enhancement**: Expand template system for more dynamic content generation +3. **Cross-Area Integration**: Explore integration between deploy and quota areas +4. **Performance Optimization**: Cache quota information and template loading + +### Monitoring and Metrics + +1. **Command Usage**: Track which new commands are most/least used +2. **Error Patterns**: Monitor common failure scenarios for improvement +3. **Integration Success**: Measure successful extension service integration +4. **User Feedback**: Collect feedback on new command structure + +## Conclusion + +This refactoring plan addresses identified standards violations while preserving the deployment and quota management capabilities introduced in PR #626. The changes include: + +1. **Proper Command Structure**: Hierarchical `CommandGroup` registration following established patterns +2. **Standard Naming**: ` ` pattern without hyphens +3. **Integration**: Leverage existing Extension commands to avoid duplication +4. **Value-Added Services**: Focus on structured guidance and templates rather than raw CLI access + +The implementation will proceed with the priority 0 items first to ensure build stability, followed by integration enhancements and optional improvements. This approach maintains the capabilities while aligning with repository standards and architectural patterns. From 8cc169c980ba852af8e4d1c55219f41ab9ebd831 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Tue, 5 Aug 2025 21:24:25 -0700 Subject: [PATCH 24/56] Add test scenarios --- docs/PR-626-Final-Recommendations.md | 464 ++++++++++++++++++++++++--- 1 file changed, 419 insertions(+), 45 deletions(-) diff --git a/docs/PR-626-Final-Recommendations.md b/docs/PR-626-Final-Recommendations.md index ef9d9b9ed..036f0869b 100644 --- a/docs/PR-626-Final-Recommendations.md +++ b/docs/PR-626-Final-Recommendations.md @@ -478,52 +478,426 @@ public class PlanAnalysisResult ## Test Scenarios -### Test Cases +### Comprehensive Manual Test Cases (30 Scenarios) + +#### Command Registration and Help Tests + +1. **Deploy Command Group Help** + - **Command**: `azmcp deploy --help` + - **Expected**: Shows deploy subcommands (app, infrastructure, pipeline, plan, architecture) + - **Validation**: All 5 subcommand groups are listed + +2. **Quota Command Group Help** + - **Command**: `azmcp quota --help` + - **Expected**: Shows quota subcommands (usage, region) + - **Validation**: Both subcommand groups are listed + +3. **Deploy App Commands Help** + - **Command**: `azmcp deploy app --help` + - **Expected**: Shows app subcommands (logs) + - **Validation**: logs command is available + +4. **Deploy Infrastructure Commands Help** + - **Command**: `azmcp deploy infrastructure --help` + - **Expected**: Shows infrastructure subcommands (rules) + - **Validation**: rules command is available + +5. **Deploy Pipeline Commands Help** + - **Command**: `azmcp deploy pipeline --help` + - **Expected**: Shows pipeline subcommands (guidance) + - **Validation**: guidance command is available + +#### Quota Command Tests + +6. **Quota Usage Check - Valid Subscription** + - **Command**: `azmcp quota usage check --subscription-id 12345678-1234-1234-1234-123456789abc` + - **Expected**: Returns quota usage information for the subscription + - **Validation**: JSON output with quota data + +7. **Quota Usage Check - Invalid Subscription** + - **Command**: `azmcp quota usage check --subscription-id invalid-sub-id` + - **Expected**: Returns authentication or validation error + - **Validation**: Clear error message about invalid subscription + +8. **Region Availability List - Specific Resource** + - **Command**: `azmcp quota region availability list --resource-type Microsoft.Compute/virtualMachines` + - **Expected**: Returns list of regions where VMs are available + - **Validation**: JSON array of region names + +9. **Region Availability List - All Resources** + - **Command**: `azmcp quota region availability list` + - **Expected**: Returns general region availability information + - **Validation**: Comprehensive region data + +10. **Quota Usage Check with Resource Group** + - **Command**: `azmcp quota usage check --subscription-id 12345678-1234-1234-1234-123456789abc --resource-group myapp-rg` + - **Expected**: Returns quota usage filtered to resource group + - **Validation**: Scoped quota information + +#### Deploy App Commands Tests + +11. **App Logs Get - Valid AZD Environment** + - **Command**: `azmcp deploy app logs get --workspace-folder ./myapp --azd-env-name dev` + - **Expected**: Returns application logs from AZD-deployed environment + - **Validation**: Log entries with timestamps + +12. **App Logs Get - Invalid Environment** + - **Command**: `azmcp deploy app logs get --workspace-folder ./myapp --azd-env-name nonexistent` + - **Expected**: Returns error about environment not found + - **Validation**: Clear error message + +13. **App Logs Get - No AZD Project** + - **Command**: `azmcp deploy app logs get --workspace-folder ./empty-folder` + - **Expected**: Returns error about missing AZD project + - **Validation**: Error indicates no azure.yaml found + +14. **App Logs Get with Service Filter** + - **Command**: `azmcp deploy app logs get --workspace-folder ./myapp --azd-env-name dev --service-name api` + - **Expected**: Returns logs filtered to specific service + - **Validation**: Logs only from specified service + +#### Deploy Infrastructure Commands Tests + +15. **Infrastructure Rules Get - Bicep Project** + - **Command**: `azmcp deploy infrastructure rules get --workspace-folder ./bicep-project` + - **Expected**: Returns Bicep-specific IaC rules and recommendations + - **Validation**: Rules specific to Bicep templates + +16. **Infrastructure Rules Get - Terraform Project** + - **Command**: `azmcp deploy infrastructure rules get --workspace-folder ./terraform-project` + - **Expected**: Returns Terraform-specific IaC rules and recommendations + - **Validation**: Rules specific to Terraform configuration + +17. **Infrastructure Rules Get - Mixed Project** + - **Command**: `azmcp deploy infrastructure rules get --workspace-folder ./mixed-project` + - **Expected**: Returns rules for detected IaC types + - **Validation**: Rules for multiple IaC frameworks + +18. **Infrastructure Rules Get - No IaC Project** + - **Command**: `azmcp deploy infrastructure rules get --workspace-folder ./no-iac` + - **Expected**: Returns general IaC guidance and getting started information + - **Validation**: General recommendations + +#### Deploy Pipeline Commands Tests + +19. **Pipeline Guidance Get - GitHub Project** + - **Command**: `azmcp deploy pipeline guidance get --workspace-folder ./github-project` + - **Expected**: Returns GitHub Actions CI/CD pipeline guidance + - **Validation**: GitHub-specific workflow recommendations + +20. **Pipeline Guidance Get - Azure DevOps Project** + - **Command**: `azmcp deploy pipeline guidance get --workspace-folder ./azdo-project` + - **Expected**: Returns Azure DevOps pipeline guidance + - **Validation**: Azure Pipelines YAML recommendations + +21. **Pipeline Guidance Get - No VCS Project** + - **Command**: `azmcp deploy pipeline guidance get --workspace-folder ./no-git` + - **Expected**: Returns general CI/CD guidance + - **Validation**: Platform-agnostic recommendations + +#### Deploy Plan Commands Tests + +22. **Plan Get - .NET Project** + - **Command**: `azmcp deploy plan get --workspace-folder ./dotnet-app` + - **Expected**: Returns deployment plan specific to .NET applications + - **Validation**: Recommendations for App Service or Container Apps + +23. **Plan Get - Node.js Project** + - **Command**: `azmcp deploy plan get --workspace-folder ./node-app` + - **Expected**: Returns deployment plan specific to Node.js applications + - **Validation**: Recommendations for suitable Azure services + +24. **Plan Get - Python Project** + - **Command**: `azmcp deploy plan get --workspace-folder ./python-app` + - **Expected**: Returns deployment plan specific to Python applications + - **Validation**: Service recommendations with Python support + +25. **Plan Get - Complex Microservices Project** + - **Command**: `azmcp deploy plan get --workspace-folder ./microservices` + - **Expected**: Returns multi-service deployment plan + - **Validation**: Orchestration and service mesh recommendations + +#### Deploy Architecture Commands Tests + +26. **Architecture Diagram Generate - Simple App** + - **Command**: `azmcp deploy architecture diagram generate --workspace-folder ./simple-app` + - **Expected**: Returns Mermaid diagram for simple application architecture + - **Validation**: Valid Mermaid syntax with basic components + +27. **Architecture Diagram Generate - Microservices** + - **Command**: `azmcp deploy architecture diagram generate --workspace-folder ./microservices` + - **Expected**: Returns complex Mermaid diagram with multiple services + - **Validation**: Comprehensive diagram with service relationships + +28. **Architecture Diagram Generate with Custom Options** + - **Command**: `azmcp deploy architecture diagram generate --workspace-folder ./myapp --include-networking --include-security` + - **Expected**: Returns detailed diagram including network and security components + - **Validation**: Enhanced diagram with additional layers + +#### Error Handling and Edge Cases + +29. **Invalid Command Structure (Legacy Format)** + - **Command**: `azmcp deploy plan-get --workspace-folder ./myapp` + - **Expected**: Command not found error + - **Validation**: Clear error indicating command format change + +30. **Missing Required Parameters** + - **Command**: `azmcp quota usage check` + - **Expected**: Returns error about missing required subscription parameter + - **Validation**: Clear parameter requirement message + +### Integration and Extension Service Tests + +#### Extension Integration Tests + +31. **AZD Service Integration** + - **Scenario**: Verify deploy commands use existing AzdCommand internally + - **Command**: `azmcp deploy app logs get --workspace-folder ./azd-project` + - **Expected**: Command successfully executes AZD operations via extension + - **Validation**: No duplication of AZD functionality + +32. **Azure CLI Service Integration** + - **Scenario**: Verify quota commands use existing AzCommand internally + - **Command**: `azmcp quota usage check --subscription-id ` + - **Expected**: Command successfully executes Azure CLI operations via extension + - **Validation**: Structured output from Azure CLI data + +### Performance and Reliability Tests + +33. **Large Project Analysis** + - **Command**: `azmcp deploy plan get --workspace-folder ./large-enterprise-app` + - **Expected**: Command completes within reasonable time (< 30 seconds) + - **Validation**: Response time and memory usage within limits + +34. **Concurrent Command Execution** + - **Scenario**: Run multiple commands simultaneously + - **Commands**: Multiple instances of quota and deploy commands + - **Expected**: All commands complete successfully without conflicts + - **Validation**: No resource contention or errors + +### Authentication and Authorization Tests + +35. **Unauthenticated Azure Access** + - **Command**: `azmcp quota usage check --subscription-id ` + - **Expected**: Clear authentication error when not logged into Azure + - **Validation**: Helpful error message with login instructions + +36. **Insufficient Permissions** + - **Command**: `azmcp quota usage check --subscription-id ` + - **Expected**: Permission denied error with clear explanation + - **Validation**: Specific permission requirements listed + +### Template and Output Format Tests + +37. **JSON Output Format** + - **Command**: `azmcp quota usage check --subscription-id --output json` + - **Expected**: Well-formed JSON response + - **Validation**: Valid JSON structure + +38. **Markdown Output Format** + - **Command**: `azmcp deploy plan get --workspace-folder ./myapp --output markdown` + - **Expected**: Formatted markdown response + - **Validation**: Proper markdown structure with headers and lists + +### Cross-Platform Tests + +39. **Windows PowerShell Execution** + - **Scenario**: Execute commands in Windows PowerShell environment + - **Command**: All deploy and quota commands + - **Expected**: Commands execute successfully on Windows + - **Validation**: No platform-specific errors + +40. **Linux/macOS Execution** + - **Scenario**: Execute commands in bash/zsh environment + - **Command**: All deploy and quota commands + - **Expected**: Commands execute successfully on Unix-like systems + - **Validation**: Cross-platform compatibility + +### Copilot Natural Language Test Prompts + +The following prompts can be used with GitHub Copilot or VS Code Copilot to test the deployment and quota functionality through natural language interactions. These prompts validate that the MCP tools are properly integrated and accessible through conversational interfaces. + +#### Quota Management Prompts + +1. **Basic Quota Check** + - **Prompt**: "Check my Azure quota usage for subscription 12345678-1234-1234-1234-123456789abc" + - **Expected**: Copilot uses `azmcp quota usage check` command + - **Validation**: Returns structured quota information + +2. **Region Availability Query** + - **Prompt**: "What regions are available for virtual machines in Azure?" + - **Expected**: Copilot uses `azmcp quota region availability list` command + - **Validation**: Returns list of regions with VM availability + +3. **Resource-Specific Quota** + - **Prompt**: "Check quota limits for compute resources in my Azure subscription" + - **Expected**: Copilot uses quota commands with appropriate filters + - **Validation**: Returns compute-specific quota data -1. **Command Registration** - ```bash - azmcp deploy app logs --help - azmcp quota usage check --help - ``` - -2. **Extension Integration** - ```bash - azmcp deploy app logs --workspace-folder ./myapp --azd-env-name dev - ``` - -3. **Hierarchical Structure** - ```bash - # Should work - azmcp deploy app --help - azmcp quota usage --help - - # Should not work (old format) - azmcp deploy azd-app-log-get - ``` - -### Deployment Workflow Tests - -4. **End-to-End Planning** - - User: "Plan deployment for my .NET app" - - Expected: Returns structured plan with next step commands - -5. **Quota Checking** - - User: "Check compute quota in East US" - - Expected: Returns structured quota information - -6. **Architecture Diagrams** - - User: "Generate diagram for my microservices" - - Expected: Returns Mermaid diagram - -### Error Handling Tests - -7. **Invalid Commands** - - Command: `azmcp deploy plan-get` (old format) - - Expected: Command not found error - -8. **Missing Dependencies** - - Command: `azmcp deploy azd logs` without AZD installed - - Expected: Clear error message with installation instructions +4. **Regional Capacity Planning** + - **Prompt**: "I need to deploy 100 VMs - which Azure regions have capacity?" + - **Expected**: Copilot uses region availability and quota commands + - **Validation**: Provides capacity recommendations + +#### Deployment Planning Prompts + +5. **Application Deployment Planning** + - **Prompt**: "Help me plan deployment for my .NET web application to Azure" + - **Expected**: Copilot uses `azmcp deploy plan get` command + - **Validation**: Returns deployment recommendations for .NET apps + +6. **Microservices Architecture Planning** + - **Prompt**: "Generate a deployment plan for my microservices application" + - **Expected**: Copilot uses deployment planning tools + - **Validation**: Returns multi-service deployment strategy + +7. **Infrastructure as Code Guidance** + - **Prompt**: "What are the best practices for Bicep templates in my project?" + - **Expected**: Copilot uses `azmcp deploy infrastructure rules get` command + - **Validation**: Returns Bicep-specific recommendations + +8. **CI/CD Pipeline Setup** + - **Prompt**: "Help me set up CI/CD for my GitHub project deploying to Azure" + - **Expected**: Copilot uses `azmcp deploy pipeline guidance get` command + - **Validation**: Returns GitHub Actions workflow recommendations + +#### Architecture and Visualization Prompts + +9. **Architecture Diagram Generation** + - **Prompt**: "Create an architecture diagram for my application deployment" + - **Expected**: Copilot uses `azmcp deploy architecture diagram generate` command + - **Validation**: Returns Mermaid diagram + +10. **Complex System Visualization** + - **Prompt**: "Generate a detailed architecture diagram including networking and security" + - **Expected**: Copilot uses diagram generation with enhanced options + - **Validation**: Returns comprehensive diagram + +#### Application Monitoring Prompts + +11. **Application Log Analysis** + - **Prompt**: "Show me logs from my AZD-deployed application in the dev environment" + - **Expected**: Copilot uses `azmcp deploy app logs get` command + - **Validation**: Returns filtered application logs + +12. **Service-Specific Monitoring** + - **Prompt**: "Get logs for the API service in my containerized application" + - **Expected**: Copilot uses app logs command with service filtering + - **Validation**: Returns service-specific log data + +#### Multi-Step Workflow Prompts + +13. **End-to-End Deployment Workflow** + - **Prompt**: "I have a new Python app - help me deploy it to Azure from scratch" + - **Expected**: Copilot uses multiple commands (plan, infrastructure, pipeline) + - **Validation**: Provides step-by-step deployment guidance + +14. **Capacity Planning Workflow** + - **Prompt**: "Plan Azure resources for a high-traffic e-commerce application" + - **Expected**: Copilot uses quota, planning, and architecture tools + - **Validation**: Comprehensive capacity and architecture recommendations + +15. **Troubleshooting Workflow** + - **Prompt**: "My Azure deployment is failing - help me diagnose the issue" + - **Expected**: Copilot uses logs, quota, and diagnostic commands + - **Validation**: Systematic troubleshooting approach + +#### Technology-Specific Prompts + +16. **Node.js Application Deployment** + - **Prompt**: "Deploy my Node.js Express app to Azure with best practices" + - **Expected**: Copilot provides Node.js-specific deployment plan + - **Validation**: Appropriate Azure service recommendations + +17. **Container Deployment Strategy** + - **Prompt**: "What's the best way to deploy my Docker containers to Azure?" + - **Expected**: Copilot recommends container-specific Azure services + - **Validation**: Container-optimized deployment strategy + +18. **Database Integration Planning** + - **Prompt**: "Plan deployment including a PostgreSQL database for my web app" + - **Expected**: Copilot includes database services in deployment plan + - **Validation**: Integrated database and application deployment + +#### Error Handling and Edge Case Prompts + +19. **Invalid Project Context** + - **Prompt**: "Generate deployment plan for this empty folder" + - **Expected**: Copilot handles missing project context gracefully + - **Validation**: Appropriate error handling and guidance + +20. **Authentication Issues** + - **Prompt**: "Check my Azure quotas (when not authenticated)" + - **Expected**: Copilot provides clear authentication guidance + - **Validation**: Helpful error messages and login instructions + +#### Advanced Integration Prompts + +21. **Cross-Service Integration** + - **Prompt**: "Plan deployment with Azure Functions, Cosmos DB, and App Service" + - **Expected**: Copilot coordinates multiple Azure services + - **Validation**: Integrated multi-service architecture + +22. **Compliance and Security Focus** + - **Prompt**: "Deploy my healthcare app with HIPAA compliance requirements" + - **Expected**: Copilot emphasizes security and compliance features + - **Validation**: Security-focused deployment recommendations + +23. **Cost Optimization Planning** + - **Prompt**: "Plan cost-effective Azure deployment for my startup application" + - **Expected**: Copilot recommends cost-optimized services and configurations + - **Validation**: Budget-conscious deployment strategy + +24. **Scaling Strategy Development** + - **Prompt**: "Plan Azure deployment that can scale from 1000 to 1 million users" + - **Expected**: Copilot provides scalable architecture recommendations + - **Validation**: Auto-scaling and performance considerations + +25. **Multi-Environment Strategy** + - **Prompt**: "Set up dev, staging, and production environments for my app" + - **Expected**: Copilot provides multi-environment deployment strategy + - **Validation**: Environment-specific configurations and pipelines + +#### Integration Testing Prompts + +26. **Tool Integration Validation** + - **Prompt**: "Use Azure CLI to check my subscription then plan deployment" + - **Expected**: Copilot seamlessly integrates existing AZ commands with new tools + - **Validation**: No duplication of CLI functionality + +27. **AZD Integration Testing** + - **Prompt**: "Get logs from my azd-deployed application and plan next deployment" + - **Expected**: Copilot uses existing AZD integration effectively + - **Validation**: Proper integration with existing AZD commands + +28. **Command Discovery Testing** + - **Prompt**: "What deployment tools are available in this Azure MCP server?" + - **Expected**: Copilot lists available deployment and quota commands + - **Validation**: Complete tool discovery and explanation + +#### Performance and Reliability Prompts + +29. **Large Project Handling** + - **Prompt**: "Analyze deployment requirements for this enterprise monorepo" + - **Expected**: Copilot handles complex project analysis efficiently + - **Validation**: Reasonable response time and comprehensive analysis + +30. **Concurrent Operation Testing** + - **Prompt**: "Check quotas while generating architecture diagram and planning deployment" + - **Expected**: Copilot handles multiple concurrent operations + - **Validation**: All operations complete successfully without conflicts + +### Expected Copilot Behavior Patterns + +When testing with these prompts, validate that Copilot: + +1. **Command Selection**: Chooses appropriate azmcp commands based on user intent +2. **Parameter Handling**: Correctly infers or prompts for required parameters +3. **Error Handling**: Provides helpful guidance when commands fail +4. **Integration**: Uses existing extension commands when appropriate +5. **Output Processing**: Formats and explains command results clearly +6. **Follow-up Actions**: Suggests logical next steps after command execution +7. **Context Awareness**: Considers project structure and environment in recommendations ## Validation Criteria From 7516979514d0c999b85ac9c1ea01e5767b11c01a Mon Sep 17 00:00:00 2001 From: xfz11 <81600993+xfz11@users.noreply.github.com> Date: Wed, 6 Aug 2025 13:43:48 +0800 Subject: [PATCH 25/56] aot safe refactoring and update template md (#16) * aot safe refactoring * update template md --- .../AzureMcp.Deploy/Services/DeployService.cs | 1 - .../Services/Util/AzdResourceLogService.cs | 133 +++++++++++++++--- .../Templates/IaCRules/appservice-rules.md | 2 +- .../Templates/Plan/deployment-plan-base.md | 3 +- 4 files changed, 113 insertions(+), 26 deletions(-) diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/DeployService.cs b/areas/deploy/src/AzureMcp.Deploy/Services/DeployService.cs index 47224d78e..96ae72c3d 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Services/DeployService.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Services/DeployService.cs @@ -11,7 +11,6 @@ namespace AzureMcp.Deploy.Services; public class DeployService() : BaseAzureService, IDeployService { - [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "")] public async Task GetAzdResourceLogsAsync( string workspaceFolder, string azdEnvName, diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs index af3ed12f4..7daa33c7a 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using Azure.Core; -using YamlDotNet.Serialization; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; namespace Areas.Deploy.Services.Util; @@ -8,7 +9,6 @@ public static class AzdResourceLogService { private const string AzureYamlFileName = "azure.yaml"; - [RequiresDynamicCode("Uses YamlDotNet for deserialization.")] public static async Task GetAzdResourceLogsAsync( TokenCredential credential, string workspaceFolder, @@ -62,7 +62,6 @@ public static async Task GetAzdResourceLogsAsync( return "No logs found."; } - [RequiresDynamicCode("Calls YamlDotNet.Serialization.DeserializerBuilder.DeserializerBuilder()")] private static Dictionary GetServicesFromAzureYaml(string workspaceFolder) { var azureYamlPath = Path.Combine(workspaceFolder, AzureYamlFileName); @@ -73,34 +72,124 @@ private static Dictionary GetServicesFromAzureYaml(string works } var yamlContent = File.ReadAllText(azureYamlPath); - var deserializer = new DeserializerBuilder().Build(); - var azureYaml = deserializer.Deserialize>(yamlContent); + + // Use AOT-safe manual YAML parsing + using var stringReader = new StringReader(yamlContent); + var parser = new YamlDotNet.Core.Parser(stringReader); + + return ParseAzureYamlServices(parser); + } - if (!azureYaml.TryGetValue("services", out var servicesObj)) + private static Dictionary ParseAzureYamlServices(YamlDotNet.Core.Parser parser) + { + var result = new Dictionary(); + + // Skip StreamStart + parser.Consume(); + + // Skip DocumentStart + parser.Consume(); + + // Start reading the root mapping + parser.Consume(); + + while (parser.Accept(out _) == false) + { + // Read key + var key = parser.Consume().Value; + + if (key == "services") + { + // Found services section + parser.Consume(); + + while (parser.Accept(out _) == false) + { + // Service name + var serviceName = parser.Consume().Value; + + // Service properties + parser.Consume(); + + string? host = null; + string? project = null; + string? language = null; + + while (parser.Accept(out _) == false) + { + var propertyKey = parser.Consume().Value; + var propertyValue = parser.Consume().Value; + + switch (propertyKey) + { + case "host": + host = propertyValue; + break; + case "project": + project = propertyValue; + break; + case "language": + language = propertyValue; + break; + } + } + + // Consume the MappingEnd for this service + parser.Consume(); + + result[serviceName] = new Service( + Host: host, + Project: project, + Language: language + ); + } + + // Consume the MappingEnd for services + parser.Consume(); + } + else + { + // Skip other top-level properties + SkipValue(parser); + } + } + + if (result.Count == 0) { throw new InvalidOperationException("No services section found in azure.yaml"); } + + return result; + } - var servicesDict = (Dictionary)servicesObj; - var result = new Dictionary(); - - foreach (var (key, value) in servicesDict) + private static void SkipValue(YamlDotNet.Core.Parser parser) + { + if (parser.Accept(out _)) { - var serviceName = key.ToString()!; - var serviceDict = (Dictionary)value; - - var service = new Service( - Host: serviceDict.TryGetValue("host", out var host) ? host?.ToString() : null, - Project: serviceDict.TryGetValue("project", out var project) ? project?.ToString() : null, - Language: serviceDict.TryGetValue("language", out var language) ? language?.ToString() : null - ); - - result[serviceName] = service; + parser.Consume(); + } + else if (parser.Accept(out _)) + { + parser.Consume(); + while (!parser.Accept(out _)) + { + SkipValue(parser); // Skip key + SkipValue(parser); // Skip value + } + parser.Consume(); + } + else if (parser.Accept(out _)) + { + parser.Consume(); + while (!parser.Accept(out _)) + { + SkipValue(parser); + } + parser.Consume(); } - - return result; } } + public record Service( string? Host = null, string? Project = null, diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/appservice-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/appservice-rules.md index 02b23b44e..e28d5a7cc 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/appservice-rules.md +++ b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/appservice-rules.md @@ -1,2 +1,2 @@ App Service Rules: -- App Service must be configured with appropriate settings. +- App Service Site Extension (Microsoft.Web/sites/siteextensions) is required for App Service deployments. diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/deployment-plan-base.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/deployment-plan-base.md index 785b3a5b1..786bb780a 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/deployment-plan-base.md +++ b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/deployment-plan-base.md @@ -49,8 +49,7 @@ Recommended Supporting Services - Key Vault(Optional): If there are dependencies such as postgresql/sql/mysql, create a Key Vault to store connection string. If not, the resource should not show. If there is a Container App, the following resources are required: - Container Registry -If there is a WebApp(App Service): -- App Service Site Extension (Microsoft.Web/sites/siteextensions): Required for App Service deployments. + Recommended Security Configurations If there is a Container App From eac669380ee4f83dd8fb1b7dcdccbf4462a43679 Mon Sep 17 00:00:00 2001 From: qianwens Date: Wed, 6 Aug 2025 15:53:10 +0800 Subject: [PATCH 26/56] move app topology definition to resource file and update the command extension implementation --- .../AzureMcp.Deploy/AzureMcp.Deploy.csproj | 1 + .../Options/DeployOptionDefinitions.cs | 133 +-------------- .../Schemas/deploy-app-topology-schema.json | 152 ++++++++++++++++++ .../Services/Util/AzdResourceLogService.cs | 34 ++-- .../Services/Util/JsonElementHelper.cs | 15 -- .../Services/Util/JsonSchemaLoader.cs | 22 +++ .../Commands/CommandExtensions.cs | 17 +- 7 files changed, 206 insertions(+), 168 deletions(-) create mode 100644 areas/deploy/src/AzureMcp.Deploy/Schemas/deploy-app-topology-schema.json delete mode 100644 areas/deploy/src/AzureMcp.Deploy/Services/Util/JsonElementHelper.cs create mode 100644 areas/deploy/src/AzureMcp.Deploy/Services/Util/JsonSchemaLoader.cs diff --git a/areas/deploy/src/AzureMcp.Deploy/AzureMcp.Deploy.csproj b/areas/deploy/src/AzureMcp.Deploy/AzureMcp.Deploy.csproj index 1e01ba74b..eb4727575 100644 --- a/areas/deploy/src/AzureMcp.Deploy/AzureMcp.Deploy.csproj +++ b/areas/deploy/src/AzureMcp.Deploy/AzureMcp.Deploy.csproj @@ -4,6 +4,7 @@ + diff --git a/areas/deploy/src/AzureMcp.Deploy/Options/DeployOptionDefinitions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/DeployOptionDefinitions.cs index 691b7be26..d2b208983 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Options/DeployOptionDefinitions.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Options/DeployOptionDefinitions.cs @@ -1,10 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json.Nodes; -using AzureMcp.Core.Areas.Server.Commands; using AzureMcp.Core.Areas.Server.Commands.ToolLoading; using AzureMcp.Core.Options; +using AzureMcp.Deploy.Services.Util; namespace AzureMcp.Deploy.Options; @@ -16,7 +15,7 @@ public static class RawMcpToolInput public static readonly Option RawMcpToolInputOption = new( $"--{RawMcpToolInputName}", - AppTopologySchema.Schema.ToJsonString() + JsonSchemaLoader.LoadAppTopologyJsonSchema() ) { IsRequired = true @@ -171,131 +170,3 @@ public static class IaCRules }; } } - -public static class AppTopologySchema -{ - public static readonly JsonObject Schema = new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["workspaceFolder"] = new JsonObject - { - ["type"] = "string", - ["description"] = "The full path of the workspace folder." - }, - ["projectName"] = new JsonObject - { - ["type"] = "string", - ["description"] = "The name of the project. This is used to generate the resource names." - }, - ["services"] = new JsonObject - { - ["type"] = "array", - ["description"] = "An array of service parameters.", - ["items"] = new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["name"] = new JsonObject - { - ["type"] = "string", - ["description"] = "The name of the service." - }, - ["path"] = new JsonObject - { - ["type"] = "string", - ["description"] = "The relative path of the service main project folder" - }, - ["language"] = new JsonObject - { - ["type"] = "string", - ["description"] = "The programming language of the service." - }, - ["port"] = new JsonObject - { - ["type"] = "string", - ["description"] = "The port number the service uses. Get this from Dockerfile for container apps. If not available, default to '80'." - }, - ["azureComputeHost"] = new JsonObject - { - ["type"] = "string", - ["description"] = "The appropriate azure service that should be used to host this service. Use containerapp if the service is containerized and has a Dockerfile.", - ["enum"] = new JsonArray("appservice", "containerapp", "function", "staticwebapp", "aks") - }, - ["dockerSettings"] = new JsonObject - { - ["type"] = "object", - ["description"] = "Docker settings for the service. This is only needed if the service's azureComputeHost is containerapp.", - ["properties"] = new JsonObject - { - ["dockerFilePath"] = new JsonObject - { - ["type"] = "string", - ["description"] = "The absolute path to the Dockerfile for the service. If the service's azureComputeHost is not containerapp, leave blank." - }, - ["dockerContext"] = new JsonObject - { - ["type"] = "string", - ["description"] = "The absolute path to the Docker build context for the service. If the service's azureComputeHost is not containerapp, leave blank." - } - }, - ["required"] = new JsonArray("dockerFilePath", "dockerContext") - }, - ["dependencies"] = new JsonObject - { - ["type"] = "array", - ["description"] = "An array of dependent services. A compute service may have a dependency on another compute service.", - ["items"] = new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["name"] = new JsonObject - { - ["type"] = "string", - ["description"] = "The name of the dependent service. Can be arbitrary, or must reference another service in the services array if referencing appservice, containerapp, staticwebapps, aks, or functionapp." - }, - ["serviceType"] = new JsonObject - { - ["type"] = "string", - ["description"] = "The name of the azure service that can be used for this dependent service.", - ["enum"] = new JsonArray("azureaisearch", "azureaiservices", "appservice", "azureapplicationinsights", "azurebotservice", "containerapp", "azurecosmosdb", "functionapp", "azurekeyvault", "aks", "azuredatabaseformysql", "azureopenai", "azuredatabaseforpostgresql", "azureprivateendpoint", "azurecacheforredis", "azuresqldatabase", "azurestorageaccount", "staticwebapp", "azureservicebus", "azuresignalrservice", "azurevirtualnetwork", "azurewebpubsub") - }, - ["connectionType"] = new JsonObject - { - ["type"] = "string", - ["description"] = "The connection authentication type of the dependency.", - ["enum"] = new JsonArray("http", "secret", "system-identity", "user-identity", "bot-connection") - }, - ["environmentVariables"] = new JsonObject - { - ["type"] = "array", - ["description"] = "An array of environment variables defined in source code to set up the connection.", - ["items"] = new JsonObject - { - ["type"] = "string" - } - } - }, - ["required"] = new JsonArray("name", "serviceType", "connectionType", "environmentVariables") - } - }, - ["settings"] = new JsonObject - { - ["type"] = "array", - ["description"] = "An array of environment variables needed to run this service. Please search the entire codebase to find environment variables.", - ["items"] = new JsonObject - { - ["type"] = "string" - } - } - }, - ["required"] = new JsonArray("name", "path", "azureComputeHost", "language", "port", "dependencies", "settings") - } - } - }, - ["required"] = new JsonArray("workspaceFolder", "services") - }; -} diff --git a/areas/deploy/src/AzureMcp.Deploy/Schemas/deploy-app-topology-schema.json b/areas/deploy/src/AzureMcp.Deploy/Schemas/deploy-app-topology-schema.json new file mode 100644 index 000000000..a0e6c4cfa --- /dev/null +++ b/areas/deploy/src/AzureMcp.Deploy/Schemas/deploy-app-topology-schema.json @@ -0,0 +1,152 @@ +{ + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "The full path of the workspace folder." + }, + "projectName": { + "type": "string", + "description": "The name of the project. This is used to generate the resource names." + }, + "services": { + "type": "array", + "description": "An array of service parameters.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the service." + }, + "path": { + "type": "string", + "description": "The relative path of the service main project folder" + }, + "language": { + "type": "string", + "description": "The programming language of the service." + }, + "port": { + "type": "string", + "description": "The port number the service uses. Get this from Dockerfile for container apps. If not available, default to \u002780\u0027." + }, + "azureComputeHost": { + "type": "string", + "description": "The appropriate azure service that should be used to host this service. Use containerapp if the service is containerized and has a Dockerfile.", + "enum": [ + "appservice", + "containerapp", + "function", + "staticwebapp", + "aks" + ] + }, + "dockerSettings": { + "type": "object", + "description": "Docker settings for the service. This is only needed if the service\u0027s azureComputeHost is containerapp.", + "properties": { + "dockerFilePath": { + "type": "string", + "description": "The absolute path to the Dockerfile for the service. If the service\u0027s azureComputeHost is not containerapp, leave blank." + }, + "dockerContext": { + "type": "string", + "description": "The absolute path to the Docker build context for the service. If the service\u0027s azureComputeHost is not containerapp, leave blank." + } + }, + "required": [ + "dockerFilePath", + "dockerContext" + ] + }, + "dependencies": { + "type": "array", + "description": "An array of dependent services. A compute service may have a dependency on another compute service.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the dependent service. Can be arbitrary, or must reference another service in the services array if referencing appservice, containerapp, staticwebapps, aks, or functionapp." + }, + "serviceType": { + "type": "string", + "description": "The name of the azure service that can be used for this dependent service.", + "enum": [ + "azureaisearch", + "azureaiservices", + "appservice", + "azureapplicationinsights", + "azurebotservice", + "containerapp", + "azurecosmosdb", + "functionapp", + "azurekeyvault", + "aks", + "azuredatabaseformysql", + "azureopenai", + "azuredatabaseforpostgresql", + "azureprivateendpoint", + "azurecacheforredis", + "azuresqldatabase", + "azurestorageaccount", + "staticwebapp", + "azureservicebus", + "azuresignalrservice", + "azurevirtualnetwork", + "azurewebpubsub" + ] + }, + "connectionType": { + "type": "string", + "description": "The connection authentication type of the dependency.", + "enum": [ + "http", + "secret", + "system-identity", + "user-identity", + "bot-connection" + ] + }, + "environmentVariables": { + "type": "array", + "description": "An array of environment variables defined in source code to set up the connection.", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "serviceType", + "connectionType", + "environmentVariables" + ] + } + }, + "settings": { + "type": "array", + "description": "An array of environment variables needed to run this service. Please search the entire codebase to find environment variables.", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "path", + "azureComputeHost", + "language", + "port", + "dependencies", + "settings" + ] + } + } + }, + "required": [ + "workspaceFolder", + "services" + ] +} \ No newline at end of file diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs index 7daa33c7a..17014af86 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs @@ -72,54 +72,54 @@ private static Dictionary GetServicesFromAzureYaml(string works } var yamlContent = File.ReadAllText(azureYamlPath); - + // Use AOT-safe manual YAML parsing using var stringReader = new StringReader(yamlContent); var parser = new YamlDotNet.Core.Parser(stringReader); - + return ParseAzureYamlServices(parser); } private static Dictionary ParseAzureYamlServices(YamlDotNet.Core.Parser parser) { var result = new Dictionary(); - + // Skip StreamStart parser.Consume(); - + // Skip DocumentStart parser.Consume(); - + // Start reading the root mapping parser.Consume(); - + while (parser.Accept(out _) == false) { // Read key var key = parser.Consume().Value; - + if (key == "services") { // Found services section parser.Consume(); - + while (parser.Accept(out _) == false) { // Service name var serviceName = parser.Consume().Value; - + // Service properties parser.Consume(); - + string? host = null; string? project = null; string? language = null; - + while (parser.Accept(out _) == false) { var propertyKey = parser.Consume().Value; var propertyValue = parser.Consume().Value; - + switch (propertyKey) { case "host": @@ -133,17 +133,17 @@ private static Dictionary ParseAzureYamlServices(YamlDotNet.Cor break; } } - + // Consume the MappingEnd for this service parser.Consume(); - + result[serviceName] = new Service( Host: host, Project: project, Language: language ); } - + // Consume the MappingEnd for services parser.Consume(); } @@ -153,12 +153,12 @@ private static Dictionary ParseAzureYamlServices(YamlDotNet.Cor SkipValue(parser); } } - + if (result.Count == 0) { throw new InvalidOperationException("No services section found in azure.yaml"); } - + return result; } diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/Util/JsonElementHelper.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/JsonElementHelper.cs deleted file mode 100644 index 8e1f59bb7..000000000 --- a/areas/deploy/src/AzureMcp.Deploy/Services/Util/JsonElementHelper.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Areas.Server.Commands.Tools.DeployTools.Util; - -public static class JsonElementHelper -{ - public static string GetStringSafe(this JsonElement element) - { - return element.ValueKind switch - { - JsonValueKind.String => element.GetString() ?? string.Empty, - JsonValueKind.Undefined => string.Empty, - JsonValueKind.Null => string.Empty, - _ => string.Empty - }; - } -} diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/Util/JsonSchemaLoader.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/JsonSchemaLoader.cs new file mode 100644 index 000000000..fbf8a9f40 --- /dev/null +++ b/areas/deploy/src/AzureMcp.Deploy/Services/Util/JsonSchemaLoader.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using AzureMcp.Core.Helpers; + +namespace AzureMcp.Deploy.Services.Util; + +public static class JsonSchemaLoader +{ + public static string LoadAppTopologyJsonSchema() + { + return LoadFileText("deploy-app-topology-schema.json"); + } + + private static string LoadFileText(string resourceFileName) + { + Assembly assembly = typeof(JsonSchemaLoader).Assembly; + string resourceName = EmbeddedResourceHelper.FindEmbeddedResource(assembly, resourceFileName); + return EmbeddedResourceHelper.ReadEmbeddedResource(assembly, resourceName); + } +} diff --git a/core/src/AzureMcp.Core/Commands/CommandExtensions.cs b/core/src/AzureMcp.Core/Commands/CommandExtensions.cs index 177262714..c5d4a1981 100644 --- a/core/src/AzureMcp.Core/Commands/CommandExtensions.cs +++ b/core/src/AzureMcp.Core/Commands/CommandExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Buffers; +using System.Text; using System.Text.Json.Nodes; using AzureMcp.Core.Helpers; @@ -84,13 +86,18 @@ public static ParseResult ParseFromRawMcpToolInput(this Command command, IReadOn } else { - var jsonObject = new JsonObject(); - foreach (var (key, value) in arguments) + var buffer = new ArrayBufferWriter(); + using (var jsonWriter = new Utf8JsonWriter(buffer)) { - jsonObject[key] = JsonNode.Parse(value.GetRawText()); + jsonWriter.WriteStartObject(); + foreach (var argument in arguments) + { + jsonWriter.WritePropertyName(argument.Key); + argument.Value.WriteTo(jsonWriter); + } + jsonWriter.WriteEndObject(); } - var jsonString = jsonObject.ToJsonString(); - args.Add(jsonString); + args.Add(Encoding.UTF8.GetString(buffer.WrittenSpan)); } return command.Parse(args.ToArray()); From fe1f8e26c91d6251432ec715c3a5d9f35770c51f Mon Sep 17 00:00:00 2001 From: qianwens Date: Wed, 6 Aug 2025 17:26:21 +0800 Subject: [PATCH 27/56] update test result - not completed --- docs/PR-626-Final-Recommendations.md | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/PR-626-Final-Recommendations.md b/docs/PR-626-Final-Recommendations.md index 036f0869b..4a068030a 100644 --- a/docs/PR-626-Final-Recommendations.md +++ b/docs/PR-626-Final-Recommendations.md @@ -1,4 +1,4 @@ -# PR #626 Final Recommendations - Deploy and Quota Commands +# PR #626 Final Recommendations - Deploy and Quota Commands (Test inprogress) ## Executive Summary @@ -480,7 +480,7 @@ public class PlanAnalysisResult ### Comprehensive Manual Test Cases (30 Scenarios) -#### Command Registration and Help Tests +#### Command Registration and Help Tests [PASS] 1. **Deploy Command Group Help** - **Command**: `azmcp deploy --help` @@ -507,7 +507,7 @@ public class PlanAnalysisResult - **Expected**: Shows pipeline subcommands (guidance) - **Validation**: guidance command is available -#### Quota Command Tests +#### Quota Command Tests 6. **Quota Usage Check - Valid Subscription** - **Command**: `azmcp quota usage check --subscription-id 12345678-1234-1234-1234-123456789abc` @@ -534,7 +534,7 @@ public class PlanAnalysisResult - **Expected**: Returns quota usage filtered to resource group - **Validation**: Scoped quota information -#### Deploy App Commands Tests +#### Deploy App Commands Tests [PASS: Covered in automation] 11. **App Logs Get - Valid AZD Environment** - **Command**: `azmcp deploy app logs get --workspace-folder ./myapp --azd-env-name dev` @@ -723,21 +723,27 @@ The following prompts can be used with GitHub Copilot or VS Code Copilot to test - **Prompt**: "Check my Azure quota usage for subscription 12345678-1234-1234-1234-123456789abc" - **Expected**: Copilot uses `azmcp quota usage check` command - **Validation**: Returns structured quota information + - **Test Result**: Pass + - **Test Observation**: Agent call the tool to check quota usage for the resource types that inferred from the project. + It would call this tool multiple times for different regions as it is not specified in the prompt. 2. **Region Availability Query** - **Prompt**: "What regions are available for virtual machines in Azure?" - **Expected**: Copilot uses `azmcp quota region availability list` command - **Validation**: Returns list of regions with VM availability + - **Test Result**: Pass 3. **Resource-Specific Quota** - **Prompt**: "Check quota limits for compute resources in my Azure subscription" - **Expected**: Copilot uses quota commands with appropriate filters - **Validation**: Returns compute-specific quota data + - **Test Result**: Pass 4. **Regional Capacity Planning** - **Prompt**: "I need to deploy 100 VMs - which Azure regions have capacity?" - **Expected**: Copilot uses region availability and quota commands - **Validation**: Provides capacity recommendations + - **Test Result**: Pass #### Deployment Planning Prompts @@ -745,11 +751,25 @@ The following prompts can be used with GitHub Copilot or VS Code Copilot to test - **Prompt**: "Help me plan deployment for my .NET web application to Azure" - **Expected**: Copilot uses `azmcp deploy plan get` command - **Validation**: Returns deployment recommendations for .NET apps + - **Model**: Claude Sonnet 4 + - **Project Context**: ESHOPWEB project with .NET. Bicep files are present. + - **Test Result**: Pass + - **Test Observation**: + Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get + Terminals called during the plan execution: az account show, azd auth login --check-status, azd env list, azd init --environment eshop-dev, azd env set AZURE_LOCATION eastus, azd provision --preview, azd up 6. **Microservices Architecture Planning** - **Prompt**: "Generate a deployment plan for my microservices application" - **Expected**: Copilot uses deployment planning tools - **Validation**: Returns multi-service deployment strategy + - **Model**: Claude Sonnet 4 + - **Project Context**: EXAMPLE-VOTING-APP project with .NET, python. Three micro services. Bicep files not present. + - **Test Result**: Pass + - **Test Observation**: + Plan generated correctly with the microservices defined as container apps in one environment. + Tools called during the plan creation: deploy plan get, iac rules get + Tools called during the plan execution: iac rules get + Terminals called during the plan execution: azd version, azd init, azd env list, azd up 7. **Infrastructure as Code Guidance** - **Prompt**: "What are the best practices for Bicep templates in my project?" From 5b1ef33b511bb2c2c02eef68682ade702d68cc6f Mon Sep 17 00:00:00 2001 From: qianwens Date: Thu, 7 Aug 2025 15:31:25 +0800 Subject: [PATCH 28/56] update test result --- .../GenerateArchitectureDiagramCommand.cs | 1 + docs/PR-626-Final-Recommendations.md | 129 +++++++++++------- 2 files changed, 84 insertions(+), 46 deletions(-) diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateArchitectureDiagramCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateArchitectureDiagramCommand.cs index 5a40089cb..b485d96d0 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateArchitectureDiagramCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateArchitectureDiagramCommand.cs @@ -23,6 +23,7 @@ public sealed class GenerateArchitectureDiagramCommand(ILogger "Generates an azure service architecture diagram for the application based on the provided app topology." + "Call this tool when the user need recommend or design the azure architecture of their application." + + "Do not call this tool when the user need detailed design of the azure architecture, such as the network topology, security design, etc." + "Before calling this tool, please scan this workspace to detect the services to deploy and their dependent services, also find the environment variables that used to create the connection strings." + "If it's a .NET Aspire application, check aspireManifest.json file if there is. Try your best to fulfill the input schema with your analyze result."; diff --git a/docs/PR-626-Final-Recommendations.md b/docs/PR-626-Final-Recommendations.md index 4a068030a..b217bc8f2 100644 --- a/docs/PR-626-Final-Recommendations.md +++ b/docs/PR-626-Final-Recommendations.md @@ -507,17 +507,17 @@ public class PlanAnalysisResult - **Expected**: Shows pipeline subcommands (guidance) - **Validation**: guidance command is available -#### Quota Command Tests +#### Quota Command Tests [PASS] 6. **Quota Usage Check - Valid Subscription** - - **Command**: `azmcp quota usage check --subscription-id 12345678-1234-1234-1234-123456789abc` + - **Command**: `azmcp quota usage check --subscription 12345678-1234-1234-1234-123456789abc --region eastus --resource-type Microsoft.Compute/virtualMachines` - **Expected**: Returns quota usage information for the subscription - **Validation**: JSON output with quota data 7. **Quota Usage Check - Invalid Subscription** - - **Command**: `azmcp quota usage check --subscription-id invalid-sub-id` + - **Command**: `azmcp quota usage check --subscription invalid-sub-id` - **Expected**: Returns authentication or validation error - - **Validation**: Clear error message about invalid subscription + - **Validation**: subscription is not required 8. **Region Availability List - Specific Resource** - **Command**: `azmcp quota region availability list --resource-type Microsoft.Compute/virtualMachines` @@ -527,12 +527,8 @@ public class PlanAnalysisResult 9. **Region Availability List - All Resources** - **Command**: `azmcp quota region availability list` - **Expected**: Returns general region availability information - - **Validation**: Comprehensive region data + - **Validation**: Clear error message about missing resource type -10. **Quota Usage Check with Resource Group** - - **Command**: `azmcp quota usage check --subscription-id 12345678-1234-1234-1234-123456789abc --resource-group myapp-rg` - - **Expected**: Returns quota usage filtered to resource group - - **Validation**: Scoped quota information #### Deploy App Commands Tests [PASS: Covered in automation] @@ -556,29 +552,19 @@ public class PlanAnalysisResult - **Expected**: Returns logs filtered to specific service - **Validation**: Logs only from specified service -#### Deploy Infrastructure Commands Tests +#### Deploy Infrastructure Commands Tests [PASS] 15. **Infrastructure Rules Get - Bicep Project** - - **Command**: `azmcp deploy infrastructure rules get --workspace-folder ./bicep-project` + - **Command**: `azmcp deploy infrastructure rules get ` - **Expected**: Returns Bicep-specific IaC rules and recommendations - **Validation**: Rules specific to Bicep templates 16. **Infrastructure Rules Get - Terraform Project** - - **Command**: `azmcp deploy infrastructure rules get --workspace-folder ./terraform-project` + - **Command**: `azmcp deploy infrastructure rules get` - **Expected**: Returns Terraform-specific IaC rules and recommendations - **Validation**: Rules specific to Terraform configuration -17. **Infrastructure Rules Get - Mixed Project** - - **Command**: `azmcp deploy infrastructure rules get --workspace-folder ./mixed-project` - - **Expected**: Returns rules for detected IaC types - - **Validation**: Rules for multiple IaC frameworks - -18. **Infrastructure Rules Get - No IaC Project** - - **Command**: `azmcp deploy infrastructure rules get --workspace-folder ./no-iac` - - **Expected**: Returns general IaC guidance and getting started information - - **Validation**: General recommendations - -#### Deploy Pipeline Commands Tests +#### Deploy Pipeline Commands Tests [PASS] 19. **Pipeline Guidance Get - GitHub Project** - **Command**: `azmcp deploy pipeline guidance get --workspace-folder ./github-project` @@ -597,27 +583,14 @@ public class PlanAnalysisResult #### Deploy Plan Commands Tests -22. **Plan Get - .NET Project** - - **Command**: `azmcp deploy plan get --workspace-folder ./dotnet-app` +22. **Plan Get - .NET Project** [Pass] + - **Command**: `azmcp deploy plan get --raw-mcp-tool-input {}` - **Expected**: Returns deployment plan specific to .NET applications - **Validation**: Recommendations for App Service or Container Apps -23. **Plan Get - Node.js Project** - - **Command**: `azmcp deploy plan get --workspace-folder ./node-app` - - **Expected**: Returns deployment plan specific to Node.js applications - - **Validation**: Recommendations for suitable Azure services - -24. **Plan Get - Python Project** - - **Command**: `azmcp deploy plan get --workspace-folder ./python-app` - - **Expected**: Returns deployment plan specific to Python applications - - **Validation**: Service recommendations with Python support -25. **Plan Get - Complex Microservices Project** - - **Command**: `azmcp deploy plan get --workspace-folder ./microservices` - - **Expected**: Returns multi-service deployment plan - - **Validation**: Orchestration and service mesh recommendations -#### Deploy Architecture Commands Tests +#### Deploy Architecture Commands Testsv [PASS] 26. **Architecture Diagram Generate - Simple App** - **Command**: `azmcp deploy architecture diagram generate --workspace-folder ./simple-app` @@ -634,7 +607,7 @@ public class PlanAnalysisResult - **Expected**: Returns detailed diagram including network and security components - **Validation**: Enhanced diagram with additional layers -#### Error Handling and Edge Cases +#### Error Handling and Edge Cases [PASS] 29. **Invalid Command Structure (Legacy Format)** - **Command**: `azmcp deploy plan-get --workspace-folder ./myapp` @@ -648,12 +621,12 @@ public class PlanAnalysisResult ### Integration and Extension Service Tests -#### Extension Integration Tests +#### Extension Integration Tests [PASS] (bellow command has no duplicated command in azd/az) 31. **AZD Service Integration** - **Scenario**: Verify deploy commands use existing AzdCommand internally - **Command**: `azmcp deploy app logs get --workspace-folder ./azd-project` - - **Expected**: Command successfully executes AZD operations via extension + - **Expected**: Command successfully executes - **Validation**: No duplication of AZD functionality 32. **Azure CLI Service Integration** @@ -662,12 +635,12 @@ public class PlanAnalysisResult - **Expected**: Command successfully executes Azure CLI operations via extension - **Validation**: Structured output from Azure CLI data -### Performance and Reliability Tests +### Performance and Reliability Tests [PASS] 33. **Large Project Analysis** - **Command**: `azmcp deploy plan get --workspace-folder ./large-enterprise-app` - **Expected**: Command completes within reasonable time (< 30 seconds) - - **Validation**: Response time and memory usage within limits + - **Validation**: Response time and memory usage within limits, the analysis is performed by agent, the tool response quickly with the plan template. 34. **Concurrent Command Execution** - **Scenario**: Run multiple commands simultaneously @@ -675,7 +648,7 @@ public class PlanAnalysisResult - **Expected**: All commands complete successfully without conflicts - **Validation**: No resource contention or errors -### Authentication and Authorization Tests +### Authentication and Authorization Tests [PASS] 35. **Unauthenticated Azure Access** - **Command**: `azmcp quota usage check --subscription-id ` @@ -775,11 +748,21 @@ The following prompts can be used with GitHub Copilot or VS Code Copilot to test - **Prompt**: "What are the best practices for Bicep templates in my project?" - **Expected**: Copilot uses `azmcp deploy infrastructure rules get` command - **Validation**: Returns Bicep-specific recommendations + - **Test Result**: Pass + - **Test Observation**: + Tools called: bicepschema get, bestpractices get, deploy iac rules get + Agent aggregated the rules from the Bicep schema best practices and iac rule, returned them in a single response. 8. **CI/CD Pipeline Setup** - **Prompt**: "Help me set up CI/CD for my GitHub project deploying to Azure" - **Expected**: Copilot uses `azmcp deploy pipeline guidance get` command - **Validation**: Returns GitHub Actions workflow recommendations + - **Test Result**: Pass + - **Test Observation**: + Tools called: deploy pipeline guidance get + Terminals called: azd pipeline config + `azd pipeline config` error: resolving bicep parameters file: substituting environment variables for environmentName: unable to parse variable name + Agent failed to resolve this error, so it switch to its own solution to setup pipeline with az command #### Architecture and Visualization Prompts @@ -787,11 +770,15 @@ The following prompts can be used with GitHub Copilot or VS Code Copilot to test - **Prompt**: "Create an architecture diagram for my application deployment" - **Expected**: Copilot uses `azmcp deploy architecture diagram generate` command - **Validation**: Returns Mermaid diagram + - **Test Result**: Pass 10. **Complex System Visualization** - **Prompt**: "Generate a detailed architecture diagram including networking and security" - **Expected**: Copilot uses diagram generation with enhanced options - **Validation**: Returns comprehensive diagram + - **Test Result**: Fail + - **Test Observation**: `azmcp deploy architecture diagram generate` cannot handle complex architecture with networking and security components. So it returned a simple diagram without those components. + Added tool description to tell agent that it cannot handle complex architecture with networking and security components. #### Application Monitoring Prompts @@ -799,11 +786,13 @@ The following prompts can be used with GitHub Copilot or VS Code Copilot to test - **Prompt**: "Show me logs from my AZD-deployed application in the dev environment" - **Expected**: Copilot uses `azmcp deploy app logs get` command - **Validation**: Returns filtered application logs + - **Test Result**: Pass 12. **Service-Specific Monitoring** - **Prompt**: "Get logs for the API service in my containerized application" - **Expected**: Copilot uses app logs command with service filtering - **Validation**: Returns service-specific log data + - **Test Result**: Pass #### Multi-Step Workflow Prompts @@ -811,16 +800,24 @@ The following prompts can be used with GitHub Copilot or VS Code Copilot to test - **Prompt**: "I have a new Python app - help me deploy it to Azure from scratch" - **Expected**: Copilot uses multiple commands (plan, infrastructure, pipeline) - **Validation**: Provides step-by-step deployment guidance + - **Test Result**: Pass + - **Test Observation**: + Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get 14. **Capacity Planning Workflow** - **Prompt**: "Plan Azure resources for a high-traffic e-commerce application" - **Expected**: Copilot uses quota, planning, and architecture tools - **Validation**: Comprehensive capacity and architecture recommendations + - **Test Result**: Pass + - **Test Observation**: + Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get + Agent designed an azure architecture with Azure Front Door and Azure CDN for high traffic, the backend is using ACA. 15. **Troubleshooting Workflow** - **Prompt**: "My Azure deployment is failing - help me diagnose the issue" - **Expected**: Copilot uses logs, quota, and diagnostic commands - **Validation**: Systematic troubleshooting approach + - **Test Result**: Pass #### Technology-Specific Prompts @@ -828,16 +825,26 @@ The following prompts can be used with GitHub Copilot or VS Code Copilot to test - **Prompt**: "Deploy my Node.js Express app to Azure with best practices" - **Expected**: Copilot provides Node.js-specific deployment plan - **Validation**: Appropriate Azure service recommendations + - **Test Result**: Pass 17. **Container Deployment Strategy** - **Prompt**: "What's the best way to deploy my Docker containers to Azure?" - **Expected**: Copilot recommends container-specific Azure services - **Validation**: Container-optimized deployment strategy + - **Test Result**: Pass + - **Test Observation**: + Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get, documentation search + Agent recommended aca, app service for container, aks and compared the differences. + 18. **Database Integration Planning** - **Prompt**: "Plan deployment including a PostgreSQL database for my web app" - **Expected**: Copilot includes database services in deployment plan - **Validation**: Integrated database and application deployment + - **Test Result**: Pass + - **Test Observation**: + Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get, deploy iac rules get + Agent created a deployment plan with PostgreSQL database and recommended using Azure Database for PostgreSQL Flexible Server. #### Error Handling and Edge Case Prompts @@ -845,38 +852,56 @@ The following prompts can be used with GitHub Copilot or VS Code Copilot to test - **Prompt**: "Generate deployment plan for this empty folder" - **Expected**: Copilot handles missing project context gracefully - **Validation**: Appropriate error handling and guidance + - **Test Result**: Pass + - **Test Observation**:Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get + Agent created a deployment plan with general recommendations, even though the folder is empty. 20. **Authentication Issues** - **Prompt**: "Check my Azure quotas (when not authenticated)" - **Expected**: Copilot provides clear authentication guidance - **Validation**: Helpful error messages and login instructions + - **Test Result**: Pass #### Advanced Integration Prompts 21. **Cross-Service Integration** - - **Prompt**: "Plan deployment with Azure Functions, Cosmos DB, and App Service" + - **Prompt**: "Plan deployment for this project, use function for the backend service, use app service for the frontend service" - **Expected**: Copilot coordinates multiple Azure services - **Validation**: Integrated multi-service architecture + - **Test Result**: Pass + - **Test Observation**: Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get + Agent created a deployment plan with Azure Functions for backend and Azure App Service for frontend. 22. **Compliance and Security Focus** - **Prompt**: "Deploy my healthcare app with HIPAA compliance requirements" - **Expected**: Copilot emphasizes security and compliance features - **Validation**: Security-focused deployment recommendations + - **Test Result**: Pass + - **Test Observation**: Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get + Agent created a deployment plan with HIPAA Requirements: Data encryption, access audit trails, secure communication, identity management 23. **Cost Optimization Planning** - **Prompt**: "Plan cost-effective Azure deployment for my startup application" - **Expected**: Copilot recommends cost-optimized services and configurations - **Validation**: Budget-conscious deployment strategy + - **Test Result**: Pass + - **Test Observation**: Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get + Agent created a deployment plan with ACA consumption plan. 24. **Scaling Strategy Development** - **Prompt**: "Plan Azure deployment that can scale from 1000 to 1 million users" - **Expected**: Copilot provides scalable architecture recommendations - **Validation**: Auto-scaling and performance considerations + - **Test Result**: Pass + - **Test Observation**: Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get + Agent created a deployment plan with AKS and its Horizontal Pod Autoscaler (HPA) Configuration. 25. **Multi-Environment Strategy** - **Prompt**: "Set up dev, staging, and production environments for my app" - **Expected**: Copilot provides multi-environment deployment strategy - **Validation**: Environment-specific configurations and pipelines + - **Test Result**: Pass + - **Test Observation**: Tools called: best practices get, azd learn, azd config show, azd init #### Integration Testing Prompts @@ -884,16 +909,22 @@ The following prompts can be used with GitHub Copilot or VS Code Copilot to test - **Prompt**: "Use Azure CLI to check my subscription then plan deployment" - **Expected**: Copilot seamlessly integrates existing AZ commands with new tools - **Validation**: No duplication of CLI functionality + - **Test Result**: Pass + - **Test Observation**: Tools called az account show, subscription list, deploy plan get 27. **AZD Integration Testing** - **Prompt**: "Get logs from my azd-deployed application and plan next deployment" - **Expected**: Copilot uses existing AZD integration effectively - **Validation**: Proper integration with existing AZD commands + - **Test Result**: Pass 28. **Command Discovery Testing** - **Prompt**: "What deployment tools are available in this Azure MCP server?" - **Expected**: Copilot lists available deployment and quota commands - **Validation**: Complete tool discovery and explanation + - **Test Result**: Pass + - **Test Observation**: Tools called: deploy learn, azd learn + Agent provided a list of azd commands and the deploy plan tool as specialized deployment tool. #### Performance and Reliability Prompts @@ -901,11 +932,17 @@ The following prompts can be used with GitHub Copilot or VS Code Copilot to test - **Prompt**: "Analyze deployment requirements for this enterprise monorepo" - **Expected**: Copilot handles complex project analysis efficiently - **Validation**: Reasonable response time and comprehensive analysis + - **Test Result**: Pass + - **Test Observation**: Tools called: deploy plan get, bestpractices get, deploy iac rules get + Agent is responsible to analyze the monorepo, the tools responded fast with the static template for the target service. 30. **Concurrent Operation Testing** - **Prompt**: "Check quotas while generating architecture diagram and planning deployment" - **Expected**: Copilot handles multiple concurrent operations - **Validation**: All operations complete successfully without conflicts + - **Test Result**: Pass + - **Test Observation**: Tools called: subscription list, quota available region list, quota usage get, deploy architecture diagram generate + ### Expected Copilot Behavior Patterns From 83ddd291a1300dbf96c90ef442fd2360d914e9c3 Mon Sep 17 00:00:00 2001 From: xfz11 <81600993+xfz11@users.noreply.github.com> Date: Fri, 8 Aug 2025 20:33:46 +0800 Subject: [PATCH 29/56] reorganize file folders (#17) * reorganize file folders * update test structure * update pipeline template * format * remove comments * fix --- .../LogsGetCommand.cs} | 11 ++-- .../DiagramGenerateCommand.cs} | 13 ++-- .../RulesGetCommand.cs} | 13 ++-- .../GuidanceGetCommand.cs} | 13 ++-- .../{PlanGetCommand.cs => Plan/GetCommand.cs} | 11 ++-- .../deploy/src/AzureMcp.Deploy/DeploySetup.cs | 49 +++++++++++---- .../Templates/PipelineTemplateParameters.cs | 51 ++++++++++++++++ .../LogsGetOptions.cs} | 4 +- .../DiagramGenerateOptions.cs} | 4 +- .../RulesGetOptions.cs} | 4 +- .../GuidanceGetOptions.cs} | 4 +- .../{PlanGetOptions.cs => Plan/GetOptions.cs} | 4 +- .../Services/Util/AzdResourceLogService.cs | 15 +---- .../Services/Util/IaCRulesTemplateUtil.cs | 2 +- .../Services/Util/PipelineGenerationUtil.cs | 59 ++++++++++--------- .../Templates/Pipeline/azcli-pipeline.md | 21 +++++++ .../Templates/Pipeline/azd-pipeline.md | 1 + .../DeployCommandTests.cs | 12 ++-- .../App/LogsGetCommandTests.cs} | 14 ++--- .../DiagramGenerateCommandTests.cs} | 15 ++--- .../Infrastructure/RulesGetCommandTests.cs} | 14 ++--- .../Pipeline/GuidanceGetCommandTests.cs} | 14 ++--- .../Plan/GetCommandTests.cs} | 12 ++-- .../Commands/QuotaJsonContext.cs | 6 +- .../AvailabilityListCommand.cs} | 11 ++-- .../CheckCommand.cs} | 11 ++-- .../AvailabilityListOptions.cs} | 4 +- .../CheckOptions.cs} | 4 +- areas/quota/src/AzureMcp.Quota/QuotaSetup.cs | 18 ++++-- .../QuotaCommandTests.cs | 6 +- .../Region/AvailabilityListCommandTests.cs} | 20 +++---- .../Usage/CheckCommandTests.cs} | 18 +++--- 32 files changed, 283 insertions(+), 175 deletions(-) rename areas/deploy/src/AzureMcp.Deploy/Commands/{AzdAppLogGetCommand.cs => App/LogsGetCommand.cs} (88%) rename areas/deploy/src/AzureMcp.Deploy/Commands/{GenerateArchitectureDiagramCommand.cs => Architecture/DiagramGenerateCommand.cs} (92%) rename areas/deploy/src/AzureMcp.Deploy/Commands/{IaCRulesGetCommand.cs => Infrastructure/RulesGetCommand.cs} (87%) rename areas/deploy/src/AzureMcp.Deploy/Commands/{PipelineGenerateCommand.cs => Pipeline/GuidanceGetCommand.cs} (88%) rename areas/deploy/src/AzureMcp.Deploy/Commands/{PlanGetCommand.cs => Plan/GetCommand.cs} (92%) create mode 100644 areas/deploy/src/AzureMcp.Deploy/Models/Templates/PipelineTemplateParameters.cs rename areas/deploy/src/AzureMcp.Deploy/Options/{DeployAppLogOptions.cs => App/LogsGetOptions.cs} (82%) rename areas/deploy/src/AzureMcp.Deploy/Options/{RawMcpToolInputOptions.cs => Architecture/DiagramGenerateOptions.cs} (78%) rename areas/deploy/src/AzureMcp.Deploy/Options/{InfraCodeRulesOptions.cs => Infrastructure/RulesGetOptions.cs} (75%) rename areas/deploy/src/AzureMcp.Deploy/Options/{PipelineGenerateOptions.cs => Pipeline/GuidanceGetOptions.cs} (84%) rename areas/deploy/src/AzureMcp.Deploy/Options/{PlanGetOptions.cs => Plan/GetOptions.cs} (90%) create mode 100644 areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/azcli-pipeline.md create mode 100644 areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/azd-pipeline.md rename areas/deploy/tests/AzureMcp.Deploy.UnitTests/{AzdAppLogGetCommandTests.cs => Commands/App/LogsGetCommandTests.cs} (94%) rename areas/deploy/tests/AzureMcp.Deploy.UnitTests/{ArchitectureDiagramTests.cs => Commands/Architecture/DiagramGenerateCommandTests.cs} (92%) rename areas/deploy/tests/AzureMcp.Deploy.UnitTests/{IaCRulesGetCommandTests.cs => Commands/Infrastructure/RulesGetCommandTests.cs} (94%) rename areas/deploy/tests/AzureMcp.Deploy.UnitTests/{PipelineGenerateCommandTests.cs => Commands/Pipeline/GuidanceGetCommandTests.cs} (94%) rename areas/deploy/tests/AzureMcp.Deploy.UnitTests/{PlanGetCommandTests.cs => Commands/Plan/GetCommandTests.cs} (93%) rename areas/quota/src/AzureMcp.Quota/Commands/{RegionCheckCommand.cs => Region/AvailabilityListCommand.cs} (90%) rename areas/quota/src/AzureMcp.Quota/Commands/{UsageCheckCommand.cs => Usage/CheckCommand.cs} (89%) rename areas/quota/src/AzureMcp.Quota/Options/{RegionCheckOptions.cs => Region/AvailabilityListOptions.cs} (83%) rename areas/quota/src/AzureMcp.Quota/Options/{UsageCheckOptions.cs => Usage/CheckOptions.cs} (78%) rename areas/quota/tests/AzureMcp.Quota.UnitTests/{RegionCheckCommandTests.cs => Commands/Region/AvailabilityListCommandTests.cs} (95%) rename areas/quota/tests/AzureMcp.Quota.UnitTests/{UsageCheckCommandTests.cs => Commands/Usage/CheckCommandTests.cs} (94%) diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/AzdAppLogGetCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs similarity index 88% rename from areas/deploy/src/AzureMcp.Deploy/Commands/AzdAppLogGetCommand.cs rename to areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs index ca3c9b086..49ccdbd6f 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/AzdAppLogGetCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs @@ -5,21 +5,22 @@ using AzureMcp.Core.Commands.Subscription; using AzureMcp.Core.Services.Telemetry; using AzureMcp.Deploy.Options; +using AzureMcp.Deploy.Options.App; using AzureMcp.Deploy.Services; using Microsoft.Extensions.Logging; -namespace AzureMcp.Deploy.Commands; +namespace AzureMcp.Deploy.Commands.App; -public sealed class AzdAppLogGetCommand(ILogger logger) : SubscriptionCommand() +public sealed class LogsGetCommand(ILogger logger) : SubscriptionCommand() { private const string CommandTitle = "Get AZD deployed App Logs"; - private readonly ILogger _logger = logger; + private readonly ILogger _logger = logger; private readonly Option _workspaceFolderOption = DeployOptionDefinitions.AzdAppLogOptions.WorkspaceFolder; private readonly Option _azdEnvNameOption = DeployOptionDefinitions.AzdAppLogOptions.AzdEnvName; private readonly Option _limitOption = DeployOptionDefinitions.AzdAppLogOptions.Limit; - public override string Name => "azd-app-log-get"; + public override string Name => "logs"; public override string Title => CommandTitle; public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true }; @@ -36,7 +37,7 @@ protected override void RegisterOptions(Command command) command.AddOption(_limitOption); } - protected override AzdAppLogOptions BindOptions(ParseResult parseResult) + protected override LogsGetOptions BindOptions(ParseResult parseResult) { var options = base.BindOptions(parseResult); options.WorkspaceFolder = parseResult.GetValueForOption(_workspaceFolderOption)!; diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateArchitectureDiagramCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs similarity index 92% rename from areas/deploy/src/AzureMcp.Deploy/Commands/GenerateArchitectureDiagramCommand.cs rename to areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs index b485d96d0..0dd2c1953 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateArchitectureDiagramCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs @@ -7,16 +7,17 @@ using AzureMcp.Core.Commands; using AzureMcp.Core.Helpers; using AzureMcp.Deploy.Options; +using AzureMcp.Deploy.Options.Architecture; using Microsoft.Extensions.Logging; -namespace AzureMcp.Deploy.Commands; +namespace AzureMcp.Deploy.Commands.Architecture; -public sealed class GenerateArchitectureDiagramCommand(ILogger logger) : BaseCommand() +public sealed class DiagramGenerateCommand(ILogger logger) : BaseCommand() { private const string CommandTitle = "Generate Architecture Diagram"; - private readonly ILogger _logger = logger; + private readonly ILogger _logger = logger; - public override string Name => "architecture-diagram-generate"; + public override string Name => "diagram"; private readonly Option _rawMcpToolInputOption = DeployOptionDefinitions.RawMcpToolInput.RawMcpToolInputOption; @@ -36,9 +37,9 @@ protected override void RegisterOptions(Command command) command.AddOption(_rawMcpToolInputOption); } - private RawMcpToolInputOptions BindOptions(ParseResult parseResult) + private DiagramGenerateOptions BindOptions(ParseResult parseResult) { - var options = new RawMcpToolInputOptions(); + var options = new DiagramGenerateOptions(); options.RawMcpToolInput = parseResult.GetValueForOption(_rawMcpToolInputOption); return options; } diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/IaCRulesGetCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs similarity index 87% rename from areas/deploy/src/AzureMcp.Deploy/Commands/IaCRulesGetCommand.cs rename to areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs index 794821127..ecd46309a 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/IaCRulesGetCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs @@ -5,22 +5,23 @@ using AzureMcp.Core.Commands; using AzureMcp.Deploy.Models; using AzureMcp.Deploy.Options; +using AzureMcp.Deploy.Options.Infrastructure; using AzureMcp.Deploy.Services.Util; using Microsoft.Extensions.Logging; -namespace AzureMcp.Deploy.Commands.InfraCodeRules; +namespace AzureMcp.Deploy.Commands.Infrastructure; -public sealed class IaCRulesGetCommand(ILogger logger) +public sealed class RulesGetCommand(ILogger logger) : BaseCommand() { private const string CommandTitle = "Get Iac(Infrastructure as Code) Rules"; - private readonly ILogger _logger = logger; + private readonly ILogger _logger = logger; private readonly Option _deploymentToolOption = DeployOptionDefinitions.IaCRules.DeploymentTool; private readonly Option _iacTypeOption = DeployOptionDefinitions.IaCRules.IacType; private readonly Option _resourceTypesOption = DeployOptionDefinitions.IaCRules.ResourceTypes; - public override string Name => "iac-rules-get"; + public override string Name => "rules"; public override string Title => CommandTitle; public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true }; @@ -37,9 +38,9 @@ protected override void RegisterOptions(Command command) command.AddOption(_resourceTypesOption); } - private InfraCodeRulesOptions BindOptions(ParseResult parseResult) + private RulesGetOptions BindOptions(ParseResult parseResult) { - var options = new InfraCodeRulesOptions(); + var options = new RulesGetOptions(); options.DeploymentTool = parseResult.GetValueForOption(_deploymentToolOption) ?? string.Empty; options.IacType = parseResult.GetValueForOption(_iacTypeOption) ?? string.Empty; options.ResourceTypes = parseResult.GetValueForOption(_resourceTypesOption) ?? string.Empty; diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/PipelineGenerateCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/Pipeline/GuidanceGetCommand.cs similarity index 88% rename from areas/deploy/src/AzureMcp.Deploy/Commands/PipelineGenerateCommand.cs rename to areas/deploy/src/AzureMcp.Deploy/Commands/Pipeline/GuidanceGetCommand.cs index 55e43e3c1..dfe4f06c1 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/PipelineGenerateCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/Pipeline/GuidanceGetCommand.cs @@ -5,23 +5,24 @@ using AzureMcp.Core.Commands.Subscription; using AzureMcp.Core.Services.Telemetry; using AzureMcp.Deploy.Options; +using AzureMcp.Deploy.Options.Pipeline; using AzureMcp.Deploy.Services.Util; using Microsoft.Extensions.Logging; -namespace AzureMcp.Deploy.Commands; +namespace AzureMcp.Deploy.Commands.Pipeline; -public sealed class PipelineGenerateCommand(ILogger logger) - : SubscriptionCommand() +public sealed class GuidanceGetCommand(ILogger logger) + : SubscriptionCommand() { private const string CommandTitle = "Get Azure Deployment CICD Pipeline Guidance"; - private readonly ILogger _logger = logger; + private readonly ILogger _logger = logger; private readonly Option _useAZDPipelineConfigOption = DeployOptionDefinitions.PipelineGenerateOptions.UseAZDPipelineConfig; private readonly Option _organizationNameOption = DeployOptionDefinitions.PipelineGenerateOptions.OrganizationName; private readonly Option _repositoryNameOption = DeployOptionDefinitions.PipelineGenerateOptions.RepositoryName; private readonly Option _githubEnvironmentNameOption = DeployOptionDefinitions.PipelineGenerateOptions.GithubEnvironmentName; - public override string Name => "cicd-pipeline-guidance-get"; + public override string Name => "guidance"; public override string Description => """ @@ -40,7 +41,7 @@ protected override void RegisterOptions(Command command) command.AddOption(_githubEnvironmentNameOption); } - protected override PipelineGenerateOptions BindOptions(ParseResult parseResult) + protected override GuidanceGetOptions BindOptions(ParseResult parseResult) { var options = base.BindOptions(parseResult); options.UseAZDPipelineConfig = parseResult.GetValueForOption(_useAZDPipelineConfigOption); diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/PlanGetCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs similarity index 92% rename from areas/deploy/src/AzureMcp.Deploy/Commands/PlanGetCommand.cs rename to areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs index ab4d9bcf4..a6c9284b2 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/PlanGetCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs @@ -4,16 +4,17 @@ using System.Diagnostics.CodeAnalysis; using AzureMcp.Core.Commands; using AzureMcp.Deploy.Options; +using AzureMcp.Deploy.Options.Plan; using AzureMcp.Deploy.Services.Util; using Microsoft.Extensions.Logging; namespace AzureMcp.Deploy.Commands.Plan; -public sealed class PlanGetCommand(ILogger logger) +public sealed class GetCommand(ILogger logger) : BaseCommand() { private const string CommandTitle = "Generate Azure Deployment Plan"; - private readonly ILogger _logger = logger; + private readonly ILogger _logger = logger; private readonly Option _workspaceFolderOption = DeployOptionDefinitions.PlanGet.WorkspaceFolder; private readonly Option _projectNameOption = DeployOptionDefinitions.PlanGet.ProjectName; @@ -21,7 +22,7 @@ public sealed class PlanGetCommand(ILogger logger) private readonly Option _provisioningToolOption = DeployOptionDefinitions.PlanGet.ProvisioningTool; private readonly Option _azdIacOptionsOption = DeployOptionDefinitions.PlanGet.AzdIacOptions; - public override string Name => "plan-get"; + public override string Name => "get"; public override string Description => """ @@ -41,9 +42,9 @@ protected override void RegisterOptions(Command command) command.AddOption(_azdIacOptionsOption); } - private PlanGetOptions BindOptions(ParseResult parseResult) + private GetOptions BindOptions(ParseResult parseResult) { - return new PlanGetOptions + return new GetOptions { WorkspaceFolder = parseResult.GetValueForOption(_workspaceFolderOption) ?? string.Empty, ProjectName = parseResult.GetValueForOption(_projectNameOption) ?? string.Empty, diff --git a/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs b/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs index e738e07d9..87234731c 100644 --- a/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs +++ b/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs @@ -3,8 +3,10 @@ using AzureMcp.Core.Areas; using AzureMcp.Core.Commands; -using AzureMcp.Deploy.Commands; -using AzureMcp.Deploy.Commands.InfraCodeRules; +using AzureMcp.Deploy.Commands.App; +using AzureMcp.Deploy.Commands.Architecture; +using AzureMcp.Deploy.Commands.Infrastructure; +using AzureMcp.Deploy.Commands.Pipeline; using AzureMcp.Deploy.Commands.Plan; using AzureMcp.Deploy.Services; using Microsoft.Extensions.DependencyInjection; @@ -22,21 +24,44 @@ public void ConfigureServices(IServiceCollection services) public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactory) { var deploy = new CommandGroup("deploy", "Deploy commands for deploying applications to Azure, including sub commands: " - + "- plan-get: generates a deployment plan to construct the infrastructure and deploy the application on Azure. Agent should read its output and generate a deploy plan in '.azure/plan.copilotmd' for execution steps, recommended azure services based on the information agent detected from project. Before calling this tool, please scan this workspace to detect the services to deploy and their dependent services; " - + "- iac-rules-get: offers guidelines for creating Bicep/Terraform files to deploy applications on Azure; " - + "- azd-app-log-get: fetch logs from log analytics workspace for Container Apps, App Services, function apps that were deployed through azd; " - + "- cicd-pipeline-guidance-get: guidance to create a CI/CD pipeline which provision Azure resources and build and deploy applications to Azure; " - + "- architecture-diagram-generate: generates an azure service architecture diagram for the application based on the provided app topology; "); + + "- plan_get: generates a deployment plan to construct the infrastructure and deploy the application on Azure. Agent should read its output and generate a deploy plan in '.azure/plan.copilotmd' for execution steps, recommended azure services based on the information agent detected from project. Before calling this tool, please scan this workspace to detect the services to deploy and their dependent services; " + + "- iac_rules_get: offers guidelines for creating Bicep/Terraform files to deploy applications on Azure; " + + "- app_logs_get: fetch logs from log analytics workspace for Container Apps, App Services, function apps that were deployed through azd; " + + "- pipeline_guidance_get: guidance to create a CI/CD pipeline which provision Azure resources and build and deploy applications to Azure; " + + "- architecture_diagram_generate: generates an azure service architecture diagram for the application based on the provided app topology; "); rootGroup.AddSubGroup(deploy); - deploy.AddCommand("plan-get", new PlanGetCommand(loggerFactory.CreateLogger())); + // Application-specific commands + var appGroup = new CommandGroup("app", "Application-specific deployment tools"); + var logsGroup = new CommandGroup("logs", "Application logs management"); + logsGroup.AddCommand("get", new LogsGetCommand(loggerFactory.CreateLogger())); + appGroup.AddSubGroup(logsGroup); + deploy.AddSubGroup(appGroup); - deploy.AddCommand("iac-rules-get", new IaCRulesGetCommand(loggerFactory.CreateLogger())); + // Infrastructure as Code commands + var iacGroup = new CommandGroup("iac", "Infrastructure as Code operations"); + var rulesGroup = new CommandGroup("rules", "Infrastructure as Code rules and guidelines"); + rulesGroup.AddCommand("get", new RulesGetCommand(loggerFactory.CreateLogger())); + iacGroup.AddSubGroup(rulesGroup); + deploy.AddSubGroup(iacGroup); - deploy.AddCommand("azd-app-log-get", new AzdAppLogGetCommand(loggerFactory.CreateLogger())); + // CI/CD Pipeline commands + var pipelineGroup = new CommandGroup("pipeline", "CI/CD pipeline operations"); + var guidanceGroup = new CommandGroup("guidance", "CI/CD pipeline guidance"); + guidanceGroup.AddCommand("get", new GuidanceGetCommand(loggerFactory.CreateLogger())); + pipelineGroup.AddSubGroup(guidanceGroup); + deploy.AddSubGroup(pipelineGroup); - deploy.AddCommand("cicd-pipeline-guidance-get", new PipelineGenerateCommand(loggerFactory.CreateLogger())); + // Deployment planning commands + var planGroup = new CommandGroup("plan", "Deployment planning operations"); + planGroup.AddCommand("get", new GetCommand(loggerFactory.CreateLogger())); + deploy.AddSubGroup(planGroup); - deploy.AddCommand("architecture-diagram-generate", new GenerateArchitectureDiagramCommand(loggerFactory.CreateLogger())); + // Architecture diagram commands + var architectureGroup = new CommandGroup("architecture", "Architecture diagram operations"); + var diagramGroup = new CommandGroup("diagram", "Architecture diagram generation"); + diagramGroup.AddCommand("generate", new DiagramGenerateCommand(loggerFactory.CreateLogger())); + architectureGroup.AddSubGroup(diagramGroup); + deploy.AddSubGroup(architectureGroup); } } diff --git a/areas/deploy/src/AzureMcp.Deploy/Models/Templates/PipelineTemplateParameters.cs b/areas/deploy/src/AzureMcp.Deploy/Models/Templates/PipelineTemplateParameters.cs new file mode 100644 index 000000000..d42792939 --- /dev/null +++ b/areas/deploy/src/AzureMcp.Deploy/Models/Templates/PipelineTemplateParameters.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AzureMcp.Deploy.Models.Templates; + +/// +/// Parameters for generating pipeline templates. +/// +public sealed class PipelineTemplateParameters +{ + /// + /// Environment name prompt text. + /// + public string EnvironmentNamePrompt { get; set; } = string.Empty; + + /// + /// Subscription ID prompt text. + /// + public string SubscriptionIdPrompt { get; set; } = string.Empty; + + /// + /// GitHub environment create command. + /// + public string EnvironmentCreateCommand { get; set; } = string.Empty; + + /// + /// JSON parameters for federated credentials. + /// + public string JsonParameters { get; set; } = string.Empty; + + /// + /// Environment argument for GitHub CLI commands. + /// + public string EnvironmentArg { get; set; } = string.Empty; + + /// + /// Converts the parameters to a dictionary for template processing. + /// + /// A dictionary containing the parameter values. + public Dictionary ToDictionary() + { + return new Dictionary + { + { nameof(EnvironmentNamePrompt), EnvironmentNamePrompt }, + { nameof(SubscriptionIdPrompt), SubscriptionIdPrompt }, + { nameof(EnvironmentCreateCommand), EnvironmentCreateCommand }, + { nameof(JsonParameters), JsonParameters }, + { nameof(EnvironmentArg), EnvironmentArg } + }; + } +} diff --git a/areas/deploy/src/AzureMcp.Deploy/Options/DeployAppLogOptions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/App/LogsGetOptions.cs similarity index 82% rename from areas/deploy/src/AzureMcp.Deploy/Options/DeployAppLogOptions.cs rename to areas/deploy/src/AzureMcp.Deploy/Options/App/LogsGetOptions.cs index 4b8f4df8f..4f8cda621 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Options/DeployAppLogOptions.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Options/App/LogsGetOptions.cs @@ -4,9 +4,9 @@ using System.Text.Json.Serialization; using AzureMcp.Core.Options; -namespace AzureMcp.Deploy.Options; +namespace AzureMcp.Deploy.Options.App; -public class AzdAppLogOptions : SubscriptionOptions +public class LogsGetOptions : SubscriptionOptions { [JsonPropertyName("workspaceFolder")] public string WorkspaceFolder { get; set; } = string.Empty; diff --git a/areas/deploy/src/AzureMcp.Deploy/Options/RawMcpToolInputOptions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/Architecture/DiagramGenerateOptions.cs similarity index 78% rename from areas/deploy/src/AzureMcp.Deploy/Options/RawMcpToolInputOptions.cs rename to areas/deploy/src/AzureMcp.Deploy/Options/Architecture/DiagramGenerateOptions.cs index 3ec626204..d345b4bc9 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Options/RawMcpToolInputOptions.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Options/Architecture/DiagramGenerateOptions.cs @@ -6,9 +6,9 @@ using AzureMcp.Core.Areas.Server.Commands.ToolLoading; using AzureMcp.Core.Options; -namespace AzureMcp.Deploy.Options; +namespace AzureMcp.Deploy.Options.Architecture; -public class RawMcpToolInputOptions : GlobalOptions +public class DiagramGenerateOptions : GlobalOptions { [JsonPropertyName(CommandFactoryToolLoader.RawMcpToolInputOptionName)] public string? RawMcpToolInput { get; set; } diff --git a/areas/deploy/src/AzureMcp.Deploy/Options/InfraCodeRulesOptions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/Infrastructure/RulesGetOptions.cs similarity index 75% rename from areas/deploy/src/AzureMcp.Deploy/Options/InfraCodeRulesOptions.cs rename to areas/deploy/src/AzureMcp.Deploy/Options/Infrastructure/RulesGetOptions.cs index 01c170207..3b512c39f 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Options/InfraCodeRulesOptions.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Options/Infrastructure/RulesGetOptions.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace AzureMcp.Deploy.Options; +namespace AzureMcp.Deploy.Options.Infrastructure; -public sealed class InfraCodeRulesOptions +public sealed class RulesGetOptions { public string DeploymentTool { get; set; } = string.Empty; public string IacType { get; set; } = string.Empty; diff --git a/areas/deploy/src/AzureMcp.Deploy/Options/PipelineGenerateOptions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/Pipeline/GuidanceGetOptions.cs similarity index 84% rename from areas/deploy/src/AzureMcp.Deploy/Options/PipelineGenerateOptions.cs rename to areas/deploy/src/AzureMcp.Deploy/Options/Pipeline/GuidanceGetOptions.cs index df9bbb4b7..6f294fb79 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Options/PipelineGenerateOptions.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Options/Pipeline/GuidanceGetOptions.cs @@ -4,9 +4,9 @@ using System.Text.Json.Serialization; using AzureMcp.Core.Options; -namespace AzureMcp.Deploy.Options; +namespace AzureMcp.Deploy.Options.Pipeline; -public class PipelineGenerateOptions : SubscriptionOptions +public class GuidanceGetOptions : SubscriptionOptions { [JsonPropertyName("useAZDPipelineConfig")] public bool UseAZDPipelineConfig { get; set; } diff --git a/areas/deploy/src/AzureMcp.Deploy/Options/PlanGetOptions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/Plan/GetOptions.cs similarity index 90% rename from areas/deploy/src/AzureMcp.Deploy/Options/PlanGetOptions.cs rename to areas/deploy/src/AzureMcp.Deploy/Options/Plan/GetOptions.cs index 4264f0e71..4b6e48057 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Options/PlanGetOptions.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Options/Plan/GetOptions.cs @@ -3,9 +3,9 @@ using System.Text.Json.Serialization; -namespace AzureMcp.Deploy.Options; +namespace AzureMcp.Deploy.Options.Plan; -public sealed class PlanGetOptions +public sealed class GetOptions { [JsonPropertyName("workspaceFolder")] public string WorkspaceFolder { get; set; } = string.Empty; diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs index 17014af86..c074226cc 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs @@ -73,7 +73,6 @@ private static Dictionary GetServicesFromAzureYaml(string works var yamlContent = File.ReadAllText(azureYamlPath); - // Use AOT-safe manual YAML parsing using var stringReader = new StringReader(yamlContent); var parser = new YamlDotNet.Core.Parser(stringReader); @@ -84,31 +83,24 @@ private static Dictionary ParseAzureYamlServices(YamlDotNet.Cor { var result = new Dictionary(); - // Skip StreamStart parser.Consume(); - // Skip DocumentStart parser.Consume(); - // Start reading the root mapping parser.Consume(); while (parser.Accept(out _) == false) { - // Read key var key = parser.Consume().Value; if (key == "services") { - // Found services section parser.Consume(); while (parser.Accept(out _) == false) { - // Service name var serviceName = parser.Consume().Value; - // Service properties parser.Consume(); string? host = null; @@ -134,7 +126,6 @@ private static Dictionary ParseAzureYamlServices(YamlDotNet.Cor } } - // Consume the MappingEnd for this service parser.Consume(); result[serviceName] = new Service( @@ -144,12 +135,10 @@ private static Dictionary ParseAzureYamlServices(YamlDotNet.Cor ); } - // Consume the MappingEnd for services parser.Consume(); } else { - // Skip other top-level properties SkipValue(parser); } } @@ -173,8 +162,8 @@ private static void SkipValue(YamlDotNet.Core.Parser parser) parser.Consume(); while (!parser.Accept(out _)) { - SkipValue(parser); // Skip key - SkipValue(parser); // Skip value + SkipValue(parser); + SkipValue(parser); } parser.Consume(); } diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/Util/IaCRulesTemplateUtil.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/IaCRulesTemplateUtil.cs index 6eadcc84c..c509807cc 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Services/Util/IaCRulesTemplateUtil.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Services/Util/IaCRulesTemplateUtil.cs @@ -188,7 +188,7 @@ private static string BuildRequiredTools(string deploymentTool, string[] resourc if (deploymentTool == DeploymentTool.Azd) { - tools.Add("azd (azd --version)"); + tools.Add("azd (azd version)"); } if (resourceTypes.Contains(AzureServiceNames.AzureContainerApp)) diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/Util/PipelineGenerationUtil.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/PipelineGenerationUtil.cs index 0fc02756e..861c46188 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Services/Util/PipelineGenerationUtil.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Services/Util/PipelineGenerationUtil.cs @@ -1,27 +1,39 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using AzureMcp.Deploy.Options; +using AzureMcp.Deploy.Models.Templates; +using AzureMcp.Deploy.Options.Pipeline; +using AzureMcp.Deploy.Services.Templates; namespace AzureMcp.Deploy.Services.Util; +/// +/// Utility class for generating pipeline guidelines using embedded template resources. +/// public static class PipelineGenerationUtil { - public static string GeneratePipelineGuidelines(PipelineGenerateOptions options) + /// + /// Generates pipeline guidelines based on the provided options. + /// + /// The guidance options containing pipeline configuration. + /// A formatted pipeline guidelines string. + public static string GeneratePipelineGuidelines(GuidanceGetOptions options) { if (options.UseAZDPipelineConfig) { - return AZDPipelinePrompt; + return TemplateService.LoadTemplate("Pipeline/azd-pipeline"); } else { - return AzCliPipelinePrompt(options); + var parameters = CreatePipelineParameters(options); + return TemplateService.ProcessTemplate("Pipeline/azcli-pipeline", parameters.ToDictionary()); } } - private static readonly string AZDPipelinePrompt = "Run \"azd pipeline config\" to help the user create a deployment pipeline.\n"; - - private static string AzCliPipelinePrompt(PipelineGenerateOptions options) + /// + /// Creates pipeline template parameters from the provided options. + /// + private static PipelineTemplateParameters CreatePipelineParameters(GuidanceGetOptions options) { const string defaultEnvironment = "dev"; var environmentNamePrompt = !string.IsNullOrEmpty(options.GithubEnvironmentName) @@ -40,31 +52,20 @@ private static string AzCliPipelinePrompt(PipelineGenerateOptions options) var environmentArg = !string.IsNullOrEmpty(options.GithubEnvironmentName) ? $"--env {options.GithubEnvironmentName}" : "--env dev"; var environmentCreateCommand = $"gh api --method PUT -H \"Accept: application/vnd.github+json\" repos/{organizationName}/{repositoryName}/environments/{environmentName}"; var jsonParameters = $"{{\"name\":\"github-federated\",\"issuer\":\"https://token.actions.githubusercontent.com\",\"subject\":\"{subjectConfig}\",\"audiences\":[\"api://AzureADTokenExchange\"]}}"; - return $""" -Help the user to set up a CI/CD pipeline to deploy to Azure with the following steps IN ORDER. **RUN the commands directly and DO NOT just give instructions. DO NOT ask user to provide information.** - - 1. First generate a Github Actions workflow file to deploy to Azure. {environmentNamePrompt} The pipeline at least contains these steps in order: - a. Azure login: login with a service principal using OIDC. DO NOT use secret. - b. Docker build - c. Deploy infrastructure: Use AZ CLI "az deployment sub/group create" command. Use "az deployment sub/group wait" to wait the deployment to finish. Refer to the infra files to set the correct parameters. - d. Azure Container Registry login: login into the container registry created in the previous step. Use "az acr list" to get the correct registry name if you are not sure. - e. Push app images to ACR - f. Deploy to hosting service. Use the infra deployment output or AZ CLI to list hosting resources. Find the name or ID of the hosting resources from "az list" if you are not sure. - - Pay attention to the name of the branches to which the pipeline is triggered. - - 2. Run '{environmentCreateCommand}' to create the environment in the repository. - 3. - {subscriptionIdPrompt} - - Run "az ad sp create-for-rbac" command to create a service principal. Grant the service principal *Contributor* role of the subscription. Also grant the service principal *User Access Administrator* - **Use Federated credentials in order to authenticate to Azure services from GitHub Actions workflows. The command is **az ad app federated-credential create --id <$service-principal-app-id> --parameters '{jsonParameters}'**. You MUST use ' and \"(DO NOT forget the slash \) in the command. Use the current Github org/repo to fill in the subject property. - - 4. Run command "gh secret set --body {environmentArg}" to configure the AZURE_CLIENT_ID, AZURE_TENANT_ID and AZURE_SUBSCRIPTION_ID of the service principal in Github secrets using Github CLI. - - ** DO NOT prompt user for any information. Find them on your own. ** -"""; + return new PipelineTemplateParameters + { + EnvironmentNamePrompt = environmentNamePrompt, + SubscriptionIdPrompt = subscriptionIdPrompt, + EnvironmentCreateCommand = environmentCreateCommand, + JsonParameters = jsonParameters, + EnvironmentArg = environmentArg + }; } + /// + /// Checks if the provided string is a valid GUID format. + /// private static bool CheckGUIDFormat(string input) { return Guid.TryParse(input, out _); diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/azcli-pipeline.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/azcli-pipeline.md new file mode 100644 index 000000000..24fb17f91 --- /dev/null +++ b/areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/azcli-pipeline.md @@ -0,0 +1,21 @@ +Help the user to set up a CI/CD pipeline to deploy to Azure with the following steps IN ORDER. **RUN the commands directly and DO NOT just give instructions. DO NOT ask user to provide information.** + + 1. First generate a Github Actions workflow file to deploy to Azure. {{EnvironmentNamePrompt}} The pipeline at least contains these steps in order: + a. Azure login: login with a service principal using OIDC. DO NOT use secret. + b. Docker build + c. Deploy infrastructure: Use AZ CLI "az deployment sub/group create" command. Use "az deployment sub/group wait" to wait the deployment to finish. Refer to the infra files to set the correct parameters. + d. Azure Container Registry login: login into the container registry created in the previous step. Use "az acr list" to get the correct registry name if you are not sure. + e. Push app images to ACR + f. Deploy to hosting service. Use the infra deployment output or AZ CLI to list hosting resources. Find the name or ID of the hosting resources from "az list" if you are not sure. + + Pay attention to the name of the branches to which the pipeline is triggered. + + 2. Run '{{EnvironmentCreateCommand}}' to create the environment in the repository. + + 3. - {{SubscriptionIdPrompt}} + - Run "az ad sp create-for-rbac" command to create a service principal. Grant the service principal *Contributor* role of the subscription. Also grant the service principal *User Access Administrator* + **Use Federated credentials in order to authenticate to Azure services from GitHub Actions workflows. The command is **az ad app federated-credential create --id <$service-principal-app-id> --parameters '{{JsonParameters}}'**. You MUST use ' and \"(DO NOT forget the slash \) in the command. Use the current Github org/repo to fill in the subject property. + + 4. Run command "gh secret set --body {{EnvironmentArg}}" to configure the AZURE_CLIENT_ID, AZURE_TENANT_ID and AZURE_SUBSCRIPTION_ID of the service principal in Github secrets using Github CLI. + + ** DO NOT prompt user for any information. Find them on your own. ** diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/azd-pipeline.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/azd-pipeline.md new file mode 100644 index 000000000..630fb0a50 --- /dev/null +++ b/areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/azd-pipeline.md @@ -0,0 +1 @@ +Run "azd pipeline config" to help the user create a deployment pipeline. diff --git a/areas/deploy/tests/AzureMcp.Deploy.LiveTests/DeployCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.LiveTests/DeployCommandTests.cs index 25cdc98a3..6e1fe4e82 100644 --- a/areas/deploy/tests/AzureMcp.Deploy.LiveTests/DeployCommandTests.cs +++ b/areas/deploy/tests/AzureMcp.Deploy.LiveTests/DeployCommandTests.cs @@ -27,7 +27,7 @@ public async Task Should_get_plan() { // act var result = await CallToolMessageAsync( - "azmcp_deploy_plan-get", + "azmcp_deploy_plan_get", new() { { "workspace-folder", "C:/" }, @@ -53,7 +53,7 @@ public async Task Should_get_infrastructure_code_rules() // act var result = await CallToolMessageAsync( - "azmcp_deploy_iac-rules-get", + "azmcp_deploy_iac_rules_get", new() { { "deployment-tool", "azd" }, @@ -69,7 +69,7 @@ public async Task Should_get_infrastructure_rules_for_terraform() { // act var result = await CallToolMessageAsync( - "azmcp_deploy_iac-rules-get", + "azmcp_deploy_iac_rules_get", new() { { "deployment-tool", "azd" }, @@ -86,7 +86,7 @@ public async Task Should_generate_pipeline() { // act var result = await CallToolMessageAsync( - "azmcp_deploy_cicd-pipeline-guidance-get", + "azmcp_deploy_pipeline_guidance_get", new() { { "subscription", _subscriptionId }, @@ -102,7 +102,7 @@ public async Task Should_generate_pipeline_with_github_details() { // act var result = await CallToolMessageAsync( - "azmcp_deploy_cicd-pipeline-guidance-get", + "azmcp_deploy_pipeline_guidance_get", new() { { "subscription", _subscriptionId }, @@ -122,7 +122,7 @@ public async Task Should_generate_pipeline_with_github_details() // { // // act // var result = await CallToolMessageAsync( - // "azmcp_deploy_azd-app-log-get", + // "azmcp_deploy_app_logs_get", // new() // { // { "subscription", _subscriptionId }, diff --git a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/AzdAppLogGetCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/App/LogsGetCommandTests.cs similarity index 94% rename from areas/deploy/tests/AzureMcp.Deploy.UnitTests/AzdAppLogGetCommandTests.cs rename to areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/App/LogsGetCommandTests.cs index b4ec9d3d6..303cca1b1 100644 --- a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/AzdAppLogGetCommandTests.cs +++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/App/LogsGetCommandTests.cs @@ -3,7 +3,7 @@ using System.CommandLine.Parsing; using AzureMcp.Core.Models.Command; -using AzureMcp.Deploy.Commands; +using AzureMcp.Deploy.Commands.App; using AzureMcp.Deploy.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -11,21 +11,21 @@ using NSubstitute.ExceptionExtensions; using Xunit; -namespace AzureMcp.Deploy.UnitTests; +namespace AzureMcp.Deploy.UnitTests.Commands.App; -public class AzdAppLogGetCommandTests +public class LogsGetCommandTests { private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IDeployService _deployService; private readonly Parser _parser; private readonly CommandContext _context; - private readonly AzdAppLogGetCommand _command; + private readonly LogsGetCommand _command; - public AzdAppLogGetCommandTests() + public LogsGetCommandTests() { - _logger = Substitute.For>(); + _logger = Substitute.For>(); _deployService = Substitute.For(); var collection = new ServiceCollection(); diff --git a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/ArchitectureDiagramTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Architecture/DiagramGenerateCommandTests.cs similarity index 92% rename from areas/deploy/tests/AzureMcp.Deploy.UnitTests/ArchitectureDiagramTests.cs rename to areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Architecture/DiagramGenerateCommandTests.cs index 5037327f3..fafb4c7e4 100644 --- a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/ArchitectureDiagramTests.cs +++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Architecture/DiagramGenerateCommandTests.cs @@ -7,23 +7,24 @@ using System.Text.Json.Serialization; using AzureMcp.Core.Models.Command; using AzureMcp.Deploy.Commands; +using AzureMcp.Deploy.Commands.Architecture; using AzureMcp.Deploy.Options; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; -namespace AzureMcp.Deploy.UnitTests; +namespace AzureMcp.Deploy.UnitTests.Commands.Architecture; -public class ArchitectureDiagramTests +public class DiagramGenerateCommandTests { private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; + private readonly ILogger _logger; - public ArchitectureDiagramTests() + public DiagramGenerateCommandTests() { - _logger = Substitute.For>(); + _logger = Substitute.For>(); var collection = new ServiceCollection(); _serviceProvider = collection.BuildServiceProvider(); @@ -33,7 +34,7 @@ public ArchitectureDiagramTests() [Fact] public async Task GenerateArchitectureDiagram_ShouldReturnNoServiceDetected() { - var command = new GenerateArchitectureDiagramCommand(_logger); + var command = new DiagramGenerateCommand(_logger); var args = command.GetCommand().Parse(["--raw-mcp-tool-input", "{\"projectName\": \"test\",\"services\": []}"]); var context = new CommandContext(_serviceProvider); var response = await command.ExecuteAsync(context, args); @@ -45,7 +46,7 @@ public async Task GenerateArchitectureDiagram_ShouldReturnNoServiceDetected() [Fact] public async Task GenerateArchitectureDiagram_ShouldReturnEncryptedDiagramUrl() { - var command = new GenerateArchitectureDiagramCommand(_logger); + var command = new DiagramGenerateCommand(_logger); var appTopology = new AppTopology() { WorkspaceFolder = "testWorkspace", diff --git a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/IaCRulesGetCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Infrastructure/RulesGetCommandTests.cs similarity index 94% rename from areas/deploy/tests/AzureMcp.Deploy.UnitTests/IaCRulesGetCommandTests.cs rename to areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Infrastructure/RulesGetCommandTests.cs index 21ac10ea8..1da450a06 100644 --- a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/IaCRulesGetCommandTests.cs +++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Infrastructure/RulesGetCommandTests.cs @@ -3,26 +3,26 @@ using System.CommandLine.Parsing; using AzureMcp.Core.Models.Command; -using AzureMcp.Deploy.Commands.InfraCodeRules; +using AzureMcp.Deploy.Commands.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; -namespace AzureMcp.Deploy.UnitTests; +namespace AzureMcp.Deploy.UnitTests.Commands.Infrastructure; -public class IaCRulesGetCommandTests +public class RulesGetCommandTests { private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly Parser _parser; private readonly CommandContext _context; - private readonly IaCRulesGetCommand _command; + private readonly RulesGetCommand _command; - public IaCRulesGetCommandTests() + public RulesGetCommandTests() { - _logger = Substitute.For>(); + _logger = Substitute.For>(); var collection = new ServiceCollection(); _serviceProvider = collection.BuildServiceProvider(); diff --git a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/PipelineGenerateCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Pipeline/GuidanceGetCommandTests.cs similarity index 94% rename from areas/deploy/tests/AzureMcp.Deploy.UnitTests/PipelineGenerateCommandTests.cs rename to areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Pipeline/GuidanceGetCommandTests.cs index a87b2c756..acef5a2d7 100644 --- a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/PipelineGenerateCommandTests.cs +++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Pipeline/GuidanceGetCommandTests.cs @@ -3,26 +3,26 @@ using System.CommandLine.Parsing; using AzureMcp.Core.Models.Command; -using AzureMcp.Deploy.Commands; +using AzureMcp.Deploy.Commands.Pipeline; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; -namespace AzureMcp.Deploy.UnitTests; +namespace AzureMcp.Deploy.UnitTests.Commands.Pipeline; -public class PipelineGenerateCommandTests +public class GuidanceGetCommandTests { private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly Parser _parser; private readonly CommandContext _context; - private readonly PipelineGenerateCommand _command; + private readonly GuidanceGetCommand _command; - public PipelineGenerateCommandTests() + public GuidanceGetCommandTests() { - _logger = Substitute.For>(); + _logger = Substitute.For>(); var collection = new ServiceCollection(); _serviceProvider = collection.BuildServiceProvider(); diff --git a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/PlanGetCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Plan/GetCommandTests.cs similarity index 93% rename from areas/deploy/tests/AzureMcp.Deploy.UnitTests/PlanGetCommandTests.cs rename to areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Plan/GetCommandTests.cs index 453b069d0..561588ad4 100644 --- a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/PlanGetCommandTests.cs +++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Plan/GetCommandTests.cs @@ -9,20 +9,20 @@ using NSubstitute; using Xunit; -namespace AzureMcp.Deploy.UnitTests; +namespace AzureMcp.Deploy.UnitTests.Commands.Plan; -public class PlanGetCommandTests +public class GetCommandTests { private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly Parser _parser; private readonly CommandContext _context; - private readonly PlanGetCommand _command; + private readonly GetCommand _command; - public PlanGetCommandTests() + public GetCommandTests() { - _logger = Substitute.For>(); + _logger = Substitute.For>(); var collection = new ServiceCollection(); _serviceProvider = collection.BuildServiceProvider(); diff --git a/areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs b/areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs index 869c89990..d9c552c6b 100644 --- a/areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs +++ b/areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs @@ -3,6 +3,8 @@ using System.Text.Json.Serialization; using AzureMcp.Quota.Commands; +using AzureMcp.Quota.Commands.Region; +using AzureMcp.Quota.Commands.Usage; using AzureMcp.Quota.Services.Util; namespace AzureMcp.Quota.Commands; @@ -12,8 +14,8 @@ namespace AzureMcp.Quota.Commands; PropertyNameCaseInsensitive = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull )] -[JsonSerializable(typeof(UsageCheckCommand.UsageCheckCommandResult))] -[JsonSerializable(typeof(RegionCheckCommand.RegionCheckCommandResult))] +[JsonSerializable(typeof(CheckCommand.UsageCheckCommandResult))] +[JsonSerializable(typeof(AvailabilityListCommand.RegionCheckCommandResult))] [JsonSerializable(typeof(UsageInfo))] [JsonSerializable(typeof(Dictionary>))] internal sealed partial class QuotaJsonContext : JsonSerializerContext diff --git a/areas/quota/src/AzureMcp.Quota/Commands/RegionCheckCommand.cs b/areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs similarity index 90% rename from areas/quota/src/AzureMcp.Quota/Commands/RegionCheckCommand.cs rename to areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs index a5f6d11c6..79233f8ed 100644 --- a/areas/quota/src/AzureMcp.Quota/Commands/RegionCheckCommand.cs +++ b/areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs @@ -6,22 +6,23 @@ using AzureMcp.Core.Models.Command; using AzureMcp.Core.Services.Telemetry; using AzureMcp.Quota.Options; +using AzureMcp.Quota.Options.Region; using AzureMcp.Quota.Services; using Microsoft.Extensions.Logging; -namespace AzureMcp.Quota.Commands; +namespace AzureMcp.Quota.Commands.Region; -public sealed class RegionCheckCommand(ILogger logger) : SubscriptionCommand() +public sealed class AvailabilityListCommand(ILogger logger) : SubscriptionCommand() { private const string CommandTitle = "Get available regions for Azure resource types"; - private readonly ILogger _logger = logger; + private readonly ILogger _logger = logger; private readonly Option _resourceTypesOption = QuotaOptionDefinitions.RegionCheck.ResourceTypes; private readonly Option _cognitiveServiceModelNameOption = QuotaOptionDefinitions.RegionCheck.CognitiveServiceModelName; private readonly Option _cognitiveServiceModelVersionOption = QuotaOptionDefinitions.RegionCheck.CognitiveServiceModelVersion; private readonly Option _cognitiveServiceDeploymentSkuNameOption = QuotaOptionDefinitions.RegionCheck.CognitiveServiceDeploymentSkuName; - public override string Name => "available-region-list"; + public override string Name => "availability"; public override string Description => """ @@ -40,7 +41,7 @@ protected override void RegisterOptions(Command command) command.AddOption(_cognitiveServiceDeploymentSkuNameOption); } - protected override RegionCheckOptions BindOptions(ParseResult parseResult) + protected override AvailabilityListOptions BindOptions(ParseResult parseResult) { var options = base.BindOptions(parseResult); options.ResourceTypes = parseResult.GetValueForOption(_resourceTypesOption) ?? string.Empty; diff --git a/areas/quota/src/AzureMcp.Quota/Commands/UsageCheckCommand.cs b/areas/quota/src/AzureMcp.Quota/Commands/Usage/CheckCommand.cs similarity index 89% rename from areas/quota/src/AzureMcp.Quota/Commands/UsageCheckCommand.cs rename to areas/quota/src/AzureMcp.Quota/Commands/Usage/CheckCommand.cs index cf06cd6b7..2fa714545 100644 --- a/areas/quota/src/AzureMcp.Quota/Commands/UsageCheckCommand.cs +++ b/areas/quota/src/AzureMcp.Quota/Commands/Usage/CheckCommand.cs @@ -6,21 +6,22 @@ using AzureMcp.Core.Models.Command; using AzureMcp.Core.Services.Telemetry; using AzureMcp.Quota.Options; +using AzureMcp.Quota.Options.Usage; using AzureMcp.Quota.Services; using AzureMcp.Quota.Services.Util; using Microsoft.Extensions.Logging; -namespace AzureMcp.Quota.Commands; +namespace AzureMcp.Quota.Commands.Usage; -public class UsageCheckCommand(ILogger logger) : SubscriptionCommand() +public class CheckCommand(ILogger logger) : SubscriptionCommand() { private const string CommandTitle = "Check Azure resources usage and quota in a region"; - private readonly ILogger _logger = logger; + private readonly ILogger _logger = logger; private readonly Option _regionOption = QuotaOptionDefinitions.QuotaCheck.Region; private readonly Option _resourceTypesOption = QuotaOptionDefinitions.QuotaCheck.ResourceTypes; - public override string Name => "usage-get"; + public override string Name => "check"; public override string Description => """ @@ -37,7 +38,7 @@ protected override void RegisterOptions(Command command) command.AddOption(_resourceTypesOption); } - protected override UsageCheckOptions BindOptions(ParseResult parseResult) + protected override CheckOptions BindOptions(ParseResult parseResult) { var options = base.BindOptions(parseResult); options.Region = parseResult.GetValueForOption(_regionOption) ?? string.Empty; diff --git a/areas/quota/src/AzureMcp.Quota/Options/RegionCheckOptions.cs b/areas/quota/src/AzureMcp.Quota/Options/Region/AvailabilityListOptions.cs similarity index 83% rename from areas/quota/src/AzureMcp.Quota/Options/RegionCheckOptions.cs rename to areas/quota/src/AzureMcp.Quota/Options/Region/AvailabilityListOptions.cs index fc3c2e24c..f2f7d4613 100644 --- a/areas/quota/src/AzureMcp.Quota/Options/RegionCheckOptions.cs +++ b/areas/quota/src/AzureMcp.Quota/Options/Region/AvailabilityListOptions.cs @@ -4,9 +4,9 @@ using System.Text.Json.Serialization; using AzureMcp.Core.Options; -namespace AzureMcp.Quota.Options; +namespace AzureMcp.Quota.Options.Region; -public sealed class RegionCheckOptions : SubscriptionOptions +public sealed class AvailabilityListOptions : SubscriptionOptions { [JsonPropertyName("resourceTypes")] public string ResourceTypes { get; set; } = string.Empty; diff --git a/areas/quota/src/AzureMcp.Quota/Options/UsageCheckOptions.cs b/areas/quota/src/AzureMcp.Quota/Options/Usage/CheckOptions.cs similarity index 78% rename from areas/quota/src/AzureMcp.Quota/Options/UsageCheckOptions.cs rename to areas/quota/src/AzureMcp.Quota/Options/Usage/CheckOptions.cs index 0b7fd04b6..213cc05a0 100644 --- a/areas/quota/src/AzureMcp.Quota/Options/UsageCheckOptions.cs +++ b/areas/quota/src/AzureMcp.Quota/Options/Usage/CheckOptions.cs @@ -4,9 +4,9 @@ using System.Text.Json.Serialization; using AzureMcp.Core.Options; -namespace AzureMcp.Quota.Options; +namespace AzureMcp.Quota.Options.Usage; -public sealed class UsageCheckOptions : SubscriptionOptions +public sealed class CheckOptions : SubscriptionOptions { [JsonPropertyName("region")] public string Region { get; set; } = string.Empty; diff --git a/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs b/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs index daa464473..10e75be10 100644 --- a/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs +++ b/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs @@ -3,7 +3,8 @@ using AzureMcp.Core.Areas; using AzureMcp.Core.Commands; -using AzureMcp.Quota.Commands; +using AzureMcp.Quota.Commands.Region; +using AzureMcp.Quota.Commands.Usage; using AzureMcp.Quota.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -19,10 +20,19 @@ public void ConfigureServices(IServiceCollection services) public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactory) { - var quota = new CommandGroup("quota", "Quota commands for getting available region for Azure resources or getting usage for Azure resource per region"); + var quota = new CommandGroup("quota", "Quota commands for Azure resource quota checking and usage analysis"); rootGroup.AddSubGroup(quota); - quota.AddCommand("usage-get", new UsageCheckCommand(loggerFactory.CreateLogger())); - quota.AddCommand("available-region-list", new RegionCheckCommand(loggerFactory.CreateLogger())); + // Resource usage and quota operations + var usageGroup = new CommandGroup("usage", "Resource usage and quota operations"); + usageGroup.AddCommand("check", new CheckCommand(loggerFactory.CreateLogger())); + quota.AddSubGroup(usageGroup); + + // Region availability operations + var regionGroup = new CommandGroup("region", "Region availability operations"); + var availabilityGroup = new CommandGroup("availability", "Region availability information"); + availabilityGroup.AddCommand("list", new AvailabilityListCommand(loggerFactory.CreateLogger())); + regionGroup.AddSubGroup(availabilityGroup); + quota.AddSubGroup(regionGroup); } } diff --git a/areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs b/areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs index 28aeb5df5..f01debd57 100644 --- a/areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs +++ b/areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs @@ -26,7 +26,7 @@ public async Task Should_check_azure_quota() // act var resourceTypes = "Microsoft.CognitiveServices, Microsoft.Compute, Microsoft.Storage, Microsoft.App, Microsoft.Network, Microsoft.MachineLearningServices, Microsoft.DBforPostgreSQL, Microsoft.HDInsight, Microsoft.Search, Microsoft.ContainerInstance"; JsonElement? result = await CallToolAsync( - "azmcp_quota_usage-get", + "azmcp_quota_usage_check", new() { { "subscription", _subscriptionId }, { "region", "eastus" }, @@ -74,7 +74,7 @@ public async Task Should_check_azure_regions() { // act var result = await CallToolAsync( - "azmcp_quota_available-region-list", + "azmcp_quota_region_availability_list", new() { { "subscription", _subscriptionId }, @@ -108,7 +108,7 @@ public async Task Should_check_regions_with_cognitive_services() { // act var result = await CallToolAsync( - "azmcp_quota_available-region-list", + "azmcp_quota_region_availability_list", new() { { "subscription", _subscriptionId }, diff --git a/areas/quota/tests/AzureMcp.Quota.UnitTests/RegionCheckCommandTests.cs b/areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Region/AvailabilityListCommandTests.cs similarity index 95% rename from areas/quota/tests/AzureMcp.Quota.UnitTests/RegionCheckCommandTests.cs rename to areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Region/AvailabilityListCommandTests.cs index 78cc3a7b7..2e7b85b96 100644 --- a/areas/quota/tests/AzureMcp.Quota.UnitTests/RegionCheckCommandTests.cs +++ b/areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Region/AvailabilityListCommandTests.cs @@ -4,7 +4,7 @@ using System.CommandLine.Parsing; using System.Text.Json; using AzureMcp.Core.Models.Command; -using AzureMcp.Quota.Commands; +using AzureMcp.Quota.Commands.Region; using AzureMcp.Quota.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -12,27 +12,27 @@ using NSubstitute.ExceptionExtensions; using Xunit; -namespace AzureMcp.Tests.Areas.Quota.UnitTests; +namespace AzureMcp.Quota.UnitTests.Commands.Region; [Trait("Area", "Quota")] -public sealed class RegionCheckCommandTests +public sealed class AvailabilityListCommandTests { private readonly IServiceProvider _serviceProvider; private readonly IQuotaService _quotaService; - private readonly ILogger _logger; - private readonly RegionCheckCommand _command; + private readonly ILogger _logger; + private readonly AvailabilityListCommand _command; private readonly Parser _parser; - public RegionCheckCommandTests() + public AvailabilityListCommandTests() { _quotaService = Substitute.For(); - _logger = Substitute.For>(); + _logger = Substitute.For>(); var services = new ServiceCollection(); services.AddSingleton(_quotaService); _serviceProvider = services.BuildServiceProvider(); - _command = new RegionCheckCommand(_logger); + _command = new AvailabilityListCommand(_logger); _parser = new Parser(_command.GetCommand()); } @@ -97,7 +97,7 @@ await _quotaService.Received(1).GetAvailableRegionsForResourceTypesAsync( PropertyNameCaseInsensitive = true }; - var response = JsonSerializer.Deserialize(json, options); + var response = JsonSerializer.Deserialize(json, options); Assert.NotNull(response); Assert.NotNull(response.AvailableRegions); Assert.Equal(5, response.AvailableRegions.Count); @@ -171,7 +171,7 @@ await _quotaService.Received(1).GetAvailableRegionsForResourceTypesAsync( PropertyNameCaseInsensitive = true }; - var response = JsonSerializer.Deserialize(json, options); + var response = JsonSerializer.Deserialize(json, options); Assert.NotNull(response); Assert.NotNull(response.AvailableRegions); Assert.Equal(3, response.AvailableRegions.Count); diff --git a/areas/quota/tests/AzureMcp.Quota.UnitTests/UsageCheckCommandTests.cs b/areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Usage/CheckCommandTests.cs similarity index 94% rename from areas/quota/tests/AzureMcp.Quota.UnitTests/UsageCheckCommandTests.cs rename to areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Usage/CheckCommandTests.cs index 0175145b4..e68daefb8 100644 --- a/areas/quota/tests/AzureMcp.Quota.UnitTests/UsageCheckCommandTests.cs +++ b/areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Usage/CheckCommandTests.cs @@ -4,7 +4,7 @@ using System.CommandLine.Parsing; using System.Text.Json; using AzureMcp.Core.Models.Command; -using AzureMcp.Quota.Commands; +using AzureMcp.Quota.Commands.Usage; using AzureMcp.Quota.Services; using AzureMcp.Quota.Services.Util; using Microsoft.Extensions.DependencyInjection; @@ -13,27 +13,27 @@ using NSubstitute.ExceptionExtensions; using Xunit; -namespace AzureMcp.Tests.Areas.Quota.UnitTests; +namespace AzureMcp.Quota.UnitTests.Commands.Usage; [Trait("Area", "Quota")] -public sealed class UsageCheckCommandTests +public sealed class CheckCommandTests { private readonly IServiceProvider _serviceProvider; private readonly IQuotaService _quotaService; - private readonly ILogger _logger; - private readonly UsageCheckCommand _command; + private readonly ILogger _logger; + private readonly CheckCommand _command; private readonly Parser _parser; - public UsageCheckCommandTests() + public CheckCommandTests() { _quotaService = Substitute.For(); - _logger = Substitute.For>(); + _logger = Substitute.For>(); var services = new ServiceCollection(); services.AddSingleton(_quotaService); _serviceProvider = services.BuildServiceProvider(); - _command = new UsageCheckCommand(_logger); + _command = new CheckCommand(_logger); _parser = new Parser(_command.GetCommand()); } @@ -107,7 +107,7 @@ await _quotaService.Received(1).GetAzureQuotaAsync( PropertyNameCaseInsensitive = true }; - var response = JsonSerializer.Deserialize(json, options); + var response = JsonSerializer.Deserialize(json, options); Assert.NotNull(response); Assert.NotNull(response.UsageInfo); Assert.Equal(2, response.UsageInfo.Count); From a7c919036758ac4a884a0a4eb8ee5e592a991de5 Mon Sep 17 00:00:00 2001 From: qianwens Date: Fri, 8 Aug 2025 20:54:51 +0800 Subject: [PATCH 30/56] update the command names in md file --- .../AzureMcp.Deploy/Commands/App/LogsGetCommand.cs | 2 +- .../Architecture/DiagramGenerateCommand.cs | 2 +- .../Commands/Infrastructure/RulesGetCommand.cs | 2 +- .../Commands/Pipeline/GuidanceGetCommand.cs | 2 +- .../Commands/Region/AvailabilityListCommand.cs | 2 +- docs/azmcp-commands.md | 14 +++++++------- e2eTests/e2eTestPrompts.md | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs index 49ccdbd6f..c71f21301 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs @@ -20,7 +20,7 @@ public sealed class LogsGetCommand(ILogger logger) : Subscriptio private readonly Option _azdEnvNameOption = DeployOptionDefinitions.AzdAppLogOptions.AzdEnvName; private readonly Option _limitOption = DeployOptionDefinitions.AzdAppLogOptions.Limit; - public override string Name => "logs"; + public override string Name => "get"; public override string Title => CommandTitle; public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true }; diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs index 0dd2c1953..6fdc01c13 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs @@ -17,7 +17,7 @@ public sealed class DiagramGenerateCommand(ILogger logge private const string CommandTitle = "Generate Architecture Diagram"; private readonly ILogger _logger = logger; - public override string Name => "diagram"; + public override string Name => "generate"; private readonly Option _rawMcpToolInputOption = DeployOptionDefinitions.RawMcpToolInput.RawMcpToolInputOption; diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs index ecd46309a..19b77f1c3 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs @@ -21,7 +21,7 @@ public sealed class RulesGetCommand(ILogger logger) private readonly Option _iacTypeOption = DeployOptionDefinitions.IaCRules.IacType; private readonly Option _resourceTypesOption = DeployOptionDefinitions.IaCRules.ResourceTypes; - public override string Name => "rules"; + public override string Name => "get"; public override string Title => CommandTitle; public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true }; diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/Pipeline/GuidanceGetCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/Pipeline/GuidanceGetCommand.cs index dfe4f06c1..30d3b3c22 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/Pipeline/GuidanceGetCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/Pipeline/GuidanceGetCommand.cs @@ -22,7 +22,7 @@ public sealed class GuidanceGetCommand(ILogger logger) private readonly Option _repositoryNameOption = DeployOptionDefinitions.PipelineGenerateOptions.RepositoryName; private readonly Option _githubEnvironmentNameOption = DeployOptionDefinitions.PipelineGenerateOptions.GithubEnvironmentName; - public override string Name => "guidance"; + public override string Name => "get"; public override string Description => """ diff --git a/areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs b/areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs index 79233f8ed..ee8bab353 100644 --- a/areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs +++ b/areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs @@ -22,7 +22,7 @@ public sealed class AvailabilityListCommand(ILogger log private readonly Option _cognitiveServiceModelVersionOption = QuotaOptionDefinitions.RegionCheck.CognitiveServiceModelVersion; private readonly Option _cognitiveServiceDeploymentSkuNameOption = QuotaOptionDefinitions.RegionCheck.CognitiveServiceDeploymentSkuName; - public override string Name => "availability"; + public override string Name => "list"; public override string Description => """ diff --git a/docs/azmcp-commands.md b/docs/azmcp-commands.md index b5a76fd9f..b2a3bf544 100644 --- a/docs/azmcp-commands.md +++ b/docs/azmcp-commands.md @@ -831,12 +831,12 @@ azmcp bicepschema get --resource-type \ ### Quota ```bash # Check the usage for Azure resources type -azmcp quota usage-get --subscription \ +azmcp quota usage check --subscription \ --region \ --resource-types # Get the available regions for the resources types -azmcp quota available-region-list --subscription \ +azmcp quota quota region availability list --subscription \ --resource-types \ [--cognitive-service-model-name ] \ [--cognitive-service-model-version ] \ @@ -846,30 +846,30 @@ azmcp quota available-region-list --subscription \ ### Deploy ```bash # Get a deployment plan for a specific project -azmcp deploy plan-get --workspace-folder \ +azmcp deploy plan get --workspace-folder \ --project-name \ --target-app-service \ --provisioning-tool \ [--azd-iac-options ] # Get the iac generation rules for the resource types -azmcp deploy iac-rules-get --deployment-tool \ +azmcp deploy iac rules get --deployment-tool \ --iac-type \ --resource-types # Get the application service log for a specific azd environment -azmcp deploy azd-app-log-get --workspace-folder \ +azmcp deploy app log get --workspace-folder \ --azd-env-name \ [--limit ] # Get the ci/cd pipeline guidance -azmcp deploy cicd-pipeline-guidance-get [--use-azd-pipeline-config ] \ +azmcp deploy pipeline guidance get [--use-azd-pipeline-config ] \ [--organization-name ] \ [--repository-name ] \ [--github-environment-name ] # Generate a mermaid architecture diagram for the application topology -azmcp deploy architecture-diagram-generate --raw-mcp-tool-input +azmcp deploy architecture diagram generate --raw-mcp-tool-input ``` ## Response Format diff --git a/e2eTests/e2eTestPrompts.md b/e2eTests/e2eTestPrompts.md index fa8a77284..cee7fecbe 100644 --- a/e2eTests/e2eTestPrompts.md +++ b/e2eTests/e2eTestPrompts.md @@ -334,8 +334,8 @@ This file contains prompts used for end-to-end testing to ensure each tool is in |:----------|:----------| | azmcp-deploy-plan-get | Create a plan to deploy this application to azure | | azmcp-deploy-iac-rules-get | Show me the rules to generate bicep scripts | -| azmcp-deploy-azd-app-log-get | Show me the log of the application deployed by azd | -| azmcp-deploy-cicd-pipeline-guidance-get | How can I create a CI/CD pipeline to deploy this app to Azure? | +| azmcp-deploy-app-logs-get | Show me the log of the application deployed by azd | +| azmcp-deploy-pipeline-guidance-get | How can I create a CI/CD pipeline to deploy this app to Azure? | | azmcp-deploy-architecture-diagram-generate | Generate the azure architecture diagram for this application | ## Quota From d675ae06906f2a53ca6e85054fa42f9c91f10c42 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Fri, 8 Aug 2025 08:03:15 -0700 Subject: [PATCH 31/56] Add code review report for PR #626 on Deploy and Quota commands --- docs/PR-626-Code-Review.md | 269 +++++++++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/PR-626-Code-Review.md diff --git a/docs/PR-626-Code-Review.md b/docs/PR-626-Code-Review.md new file mode 100644 index 000000000..505857312 --- /dev/null +++ b/docs/PR-626-Code-Review.md @@ -0,0 +1,269 @@ +# Code Review Report: PR #626 — Deploy and Quota Commands + +This report reviews PR #626 against the guidance in `docs/PR-626-Final-Recommendations.md` and repository standards. It summarizes what’s aligned, what’s missing, risks, and concrete next steps to reach compliance and improve maintainability. + +## Review checklist + +- [ ] Command groups follow hierarchical structure and consistent naming +- [ ] Command names use pattern (no hyphens) +- [ ] Files/namespaces reorganized per target structure +- [ ] Integration leverages existing extension services (az/azd) where applicable +- [ ] AOT/trimming safety validated (no RequiresDynamicCode violations) +- [ ] Uses System.Text.Json (no Newtonsoft) +- [ ] Tests updated and sufficient coverage +- [ ] Documentation updated (commands, migration notes, prompts) +- [ ] CHANGELOG and spelling checks updated + +## Findings + +### 1) Command structure and naming + +- Deploy area now uses hierarchical groups and verbs: + - deploy app logs get + - deploy infrastructure rules get + - deploy pipeline guidance get + - deploy plan get + - deploy architecture diagram generate +- Quota area uses hierarchical groups and verbs: + - quota usage check + - quota region availability list +- Registration uses CommandGroup with nested subgroups for both areas. Good. +- Minor issue: the top-level deploy CommandGroup description string still references legacy hyphenated names (plan_get, iac_rules_get, etc.). This is cosmetic but inconsistent with the new pattern. + +Status: Mostly PASS (fix description text). + +### 2) File/folder organization and namespaces + +- Deploy: Commands and Options are placed under App/Infrastructure/Pipeline/Plan/Architecture subfolders. Services and Templates folders added. JsonSourceGeneration context present. Good. +- Quota: Commands moved under Usage/ and Region/, Options split accordingly. Good. + +Status: PASS. + +### 3) Integration with existing extension commands + +- Guidance recommends reusing the existing extension (Az/Azd) services to avoid duplication and clarify ownership. Current DeployService calls a local AzdResourceLogService to fetch logs via workspace parsing + Monitor Query. There’s no explicit reuse of the extension Az/Azd command surface. +- The PR’s intent acknowledges that azd-app-logs is temporary until azd exposes a native command. That’s fine short-term, but we should still abstract this behind interfaces and consider delegating through the extension service to reduce duplication and future migrations. + +Status: PARTIAL. Needs refactor to consume extension services (IAzService/IAzdService) or document/ticket the temporary approach with deprecation plan. + +### 4) AOT/trimming safety + +- Projects declare true. +- Uses System.Text.Json source generation in Deploy (DeployJsonContext) and Quota (QuotaJsonContext). Good. +- YamlDotNet is used, but via low-level parser (events) rather than POCO deserialization. This reduces reflection/AOT risk, but we should still run the AOT analyzer script to confirm there are no warnings and consider linker descriptors if needed. +- Reflection is used to load embedded resources (GetManifestResourceStream). That’s typically safe with embedded resources; ensure resources are included (they are) and names are stable. + +Status: LIKELY PASS (verify with analyzer). Action item to run eng/scripts/Analyze-AOT-Compact.ps1 and address any findings. + +### 5) JSON library choice + +- System.Text.Json is used across new code. No Newtonsoft dependencies in these areas. Good. + +Status: PASS. + +### 6) Tests + +- Unit tests added for Quota commands (AvailabilityListCommandTests, CheckCommandTests) and Deploy command tests exist for the reorganized classes (LogsGetCommandTests, RulesGetCommandTests, GuidanceGetCommandTests, etc.). +- Tests validate parsing, happy paths, errors, and output shapes. Nice. + +Status: PASS (keep adding targeted edge cases; see gaps below). + +### 7) Documentation + +- docs/azmcp-commands.md updated to include Deploy/Quota sections. +- Issue: one example contains a duplicated group token: “azmcp quota quota region availability list”. +- E2E prompts updated; several questions remain unresolved in PR comments (e.g., value-add vs existing tools, pipeline setup guidance). Consider consolidating with migration notes from legacy hyphenated names and calling out new structure explicitly. + +Status: PARTIAL. Fix command typos and add a short “migration from legacy names” section. + +### 8) CHANGELOG and spelling + +- PR checklist flags CHANGELOG and spelling as incomplete. These need to be completed before merge. + +Status: FAIL (to be addressed). + +## Gaps and risks + +- Integration duplication: DeployService/AzdResourceLogService duplicates behavior that would better live behind extension services. Risk of divergence and confusion with azd MCP server effort. Mitigate by factoring through IAzService/IAzdService and marking the logs command as temporary. +- Documentation accuracy: Minor typos/conflicts can mislead users (e.g., duplicated “quota” token). Add migration notes to reduce confusion for users familiar with prior hyphenated commands. +- AOT/trimming: While likely safe, adding YamlDotNet warrants a quick AOT scan and consideration of linker configs if warnings appear. +- CI failures: Current PR pipeline shows failures for several platform builds (linux_x64, osx_x64, win_x64). Investigate before merge. + +## Targeted recommendations and next steps + +P0 (must do before merge) +- Fix deploy CommandGroup description to remove legacy hyphenated names and align with actual subcommands. +- Fix docs/azmcp-commands.md typos (“azmcp quota quota region availability list” → “azmcp quota region availability list”). +- Add CHANGELOG entry summarizing new areas (deploy/quota), command structure, and any breaking command name changes. +- Run spelling check: .\\eng\\common\\spelling\\Invoke-Cspell.ps1 and address findings. +- Investigate the CI failures on the PR (linux_x64, osx_x64, win_x64 jobs) and resolve. + +P1 (integration and maintainability) +- Abstract DeployService’s log retrieval to use extension services (IAzdService) where possible, or encapsulate current logic behind an interface to ease migration when azd exposes native logs. +- Consider adding a light project reference to the extension area if needed for reuse (as recommended), or explicitly document why it’s deferred. +- Add “migration notes” to docs explaining the new hierarchical verbs and mapping from legacy hyphenated names. +- Expand tests for: + - Architecture diagram: invalid/malformed raw-mcp-tool-input and large service graphs. + - Quota parsing: empty/whitespace resource-types; mixed casing; extreme list lengths. + +P2 (optional enhancements) +- Template system: Centralize all prompt content via TemplateService (you’ve started this); document template names and parameters; add unit tests for template retrieval. +- Performance: Consider caching region availability lookups and IaC rule templates where applicable. +- AOT verification: Add a short note to the PR description capturing AOT analysis results and any linker config changes (if needed). + +## Compliance matrix vs Final Recommendations + +- Command Groups (quota, deploy subgroups): Done. +- Command Structure Changes (verbs): Done; minor description text cleanup pending. +- Integration Strategy (reuse az/azd): Partially done; not yet wired to extension services. +- File/Folder Reorg: Done. +- Namespace Updates: Done. +- Project File Updates (embedded resources): Done. Extension project reference: Not added (consider per integration plan). +- Registration Updates: Done (areas registered in core setup). +- Template System: Implemented; continue consolidating prompts. +- Plan command scope: Current implementation returns a plan template; it no longer writes files directly. Aligned with guidance. + +## Quick quality gates snapshot + +- Build: PR pipeline shows failures on multiple x64 jobs (linux/osx/win). Needs investigation. +- Lint/Spelling: Spelling unchecked; run script and fix. +- Tests: New unit tests present; ensure they run in CI and are green after any fixes. +- Smoke/help: Verify `azmcp deploy --help` and `azmcp quota --help` show the expected hierarchy post-changes. + +## Appendix: Suggested documentation deltas + +- docs/azmcp-commands.md + - Fix: “azmcp quota quota region availability list” → “azmcp quota region availability list”. + - Add “Migration from legacy hyphenated names” table mapping plan-get → plan get, iac-rules-get → infrastructure rules get, etc. +- docs/new-command.md + - Include example of hierarchical registration via CommandGroup and guidance on naming. + +--- + +Completion summary +- The PR largely meets the architectural reorg, naming, and testability goals. The biggest remaining items are integration reuse (az/azd), small docs fixes, CHANGELOG/spelling, and CI stabilization. Addressing the P0/P1 items above should make this PR ready to merge. + +## Exhaustive merge-readiness checklist + +Note: Priority tags — [P0] must before merge, [P1] should before merge, [P2] nice-to-have. + +### Command design and UX +- [ ] [P0] Verify command hierarchy and verbs match guidance exactly: + - deploy app logs get + - deploy infrastructure rules get + - deploy pipeline guidance get + - deploy plan get + - deploy architecture diagram generate + - quota usage check + - quota region availability list +- [ ] [P0] Remove legacy/hyphenated names and outdated descriptions (e.g., DeploySetup group description). + - Files: areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs; areas/quota/src/AzureMcp.Quota/QuotaSetup.cs (verify help text) +- [ ] [P0] Ensure --help text is concise and consistent; clearly mark required vs optional options. +- [ ] [P1] Provide help examples for each command (typical + edge-case for raw-mcp-tool-input). +- [ ] [P1] Confirm output shapes and property casing are consistent and documented. + +### Options and input validation +- [ ] [P0] Validate raw-mcp-tool-input JSON: required fields present; unknown fields behavior defined; helpful errors. +- [ ] [P0] Validate quota inputs: region/resource types normalized; reject empty/invalid sets; friendly messages. +- [ ] [P0] Validate logs query time windows and limits; set sane defaults and max bounds. +- [ ] [P1] Add JSON schema snippets for raw-mcp-tool-input in docs and link from --help. +- [ ] [P2] Robust list parsers (comma/space/newline with trimming) + tests. + - Files: areas/deploy/src/AzureMcp.Deploy/Options/DeployOptionDefinitions.cs; areas/deploy/src/AzureMcp.Deploy/Options/App/LogsGetOptions.cs; areas/quota/src/AzureMcp.Quota/Commands/Usage/CheckCommand.cs; areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs + +### Code structure, AOT, and serialization +- [ ] [P0] Use primary constructors where applicable in new classes. +- [ ] [P0] Use System.Text.Json only; no Newtonsoft. +- [ ] [P0] Ensure all DTOs are covered by source-gen contexts (DeployJsonContext, QuotaJsonContext) in all (de)serializations. +- [ ] [P0] Prefer static members where possible; avoid reflection/dynamic not safe for AOT. +- [ ] [P0] Run AOT/trimming analysis; address warnings (preserve attributes/linker config if needed). +- [ ] [P1] Add JSON round-trip tests proving source-gen coverage. +- [ ] [P2] Enforce culture-invariant formatting/parsing (dates, numbers, casing). + - Files: areas/deploy/src/AzureMcp.Deploy/Commands/DeployJsonContext.cs; areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs + +### Services and Azure SDK usage +- [ ] [P0] Use IHttpClientFactory and reuse Azure SDK clients appropriately; avoid per-call instantiation. +- [ ] [P0] Use Azure SDK default retries/timeouts; avoid custom retries unless justified. +- [ ] [P0] Respect cloud/authority from environment; support sovereign clouds. +- [ ] [P0] Use TokenCredential correctly; do not accept/store secrets directly. +- [ ] [P1] Abstract log retrieval behind an interface and prefer routing via extension services (IAzService/IAzdService) to reduce duplication. +- [ ] [P2] Keep diagnostics minimal, opt-in, and scrub PII. + - Files: areas/deploy/src/AzureMcp.Deploy/Services/DeployService.cs; areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs + +### Templates, resources, and I/O +- [ ] [P0] Ensure embedded templates/rules are included and load via correct manifest names. +- [ ] [P0] Validate template outputs are non-null and meaningful; handle missing resource errors. +- [ ] [P1] Tests confirming presence and expected content of embedded resources. +- [ ] [P2] Cache static templates in-memory (thread-safe) to reduce I/O. + - Files: areas/deploy/src/AzureMcp.Deploy/Services/Templates/TemplateService.cs; areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs; areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs + +### Security and robustness +- [ ] [P0] Bound and sanitize inputs for diagram generation; warn or fail cleanly if payload exceeds safe URL length. +- [ ] [P0] Encode all URLs; avoid external calls based on untrusted input (Azure SDK excepted). +- [ ] [P1] Review YamlDotNet usage; handle malformed YAML with clear errors. +- [ ] [P1] Plumb CancellationToken through long-running operations (logs queries). +- [ ] [P2] Consider allowlist constraints for resource types/locations if applicable. + - Files: areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs; areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs + +### Testing +- [ ] [P0] Per-command tests (success + 1–2 error cases): + - deploy/app/logs/get (invalid YAML, empty resources, query timeout) + - deploy/infrastructure/rules/get (resource presence) + - deploy/plan/get (template present) + - deploy/architecture/diagram/generate (bad/large JSON, valid graph) + - quota/usage/check (invalid/empty resource types, mixed casing) + - quota/region/availability/list (filters, cognitive services variants) +- [ ] [P0] Tests to ensure JsonSerializerContext is used (no reflection fallback at runtime). +- [ ] [P0] Tests asserting output contracts (shape, casing, required properties). +- [ ] [P1] Integration tests with recorded fixtures/test proxy where feasible. +- [ ] [P1] Update E2E prompt tests with new commands and sample payloads. +- [ ] [P2] Concurrency and large-input perf smoke tests. + - Files (tests to extend/verify): areas/deploy/tests/AzureMcp.Deploy.UnitTests/**; areas/quota/tests/AzureMcp.Quota.UnitTests/**; core/tests/AzureMcp.Tests/** + +### Documentation +- [ ] [P0] Fix typos (e.g., docs/azmcp-commands.md “azmcp quota quota …” → “azmcp quota …”). +- [ ] [P0] Document each command: synopsis, options, example inputs/outputs, error cases, JSON schemas. +- [ ] [P0] Update CHANGELOG.md summarizing new areas/commands and notable behavior. +- [ ] [P1] Troubleshooting notes (auth issues, timeouts, payload too large for diagrams). +- [ ] [P2] Link to architecture decisions for diagram/templates. + - Files: docs/azmcp-commands.md; CHANGELOG.md; docs/new-command.md; docs/PR-626-Code-Review.md (this document) + +### Repo hygiene and engineering system +- [ ] [P0] One class/interface per file; remove dead code/unused usings; consistent naming. +- [ ] [P0] Ensure copyright headers; run header script. +- [ ] [P0] Run local verifications: + - ./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx + - .\eng\common\spelling\Invoke-Cspell.ps1 +- [ ] [P0] dotnet build of AzureMcp.sln passes cleanly; address warnings-as-errors. +- [ ] [P1] Run Analyze-AOT-Compact.ps1 and Analyze-Code.ps1; address issues. +- [ ] [P2] Verify package versions pinned in Directory.Packages.props match standards. + - Files: Directory.Packages.props; eng/scripts/*.ps1; eng/common/spelling/*; solution-wide + +### CI and cross-platform readiness +- [ ] [P0] Investigate and fix failing CI jobs (linux_x64, osx_x64, win_x64); reproduce locally as needed. +- [ ] [P0] Ensure tests are stable (not time/date/region dependent); deflake if needed. +- [ ] [P1] Validate trimming/AOT publish on CI; ensure any publish profiles succeed. +- [ ] [P2] Add light smoke validation for each new command in CI (mock/dry-run). + - Files/areas: eng/pipelines/**; failing job logs in GitHub Actions; areas/deploy/**; areas/quota/** + +### User experience polish +- [ ] [P0] Consistent exit codes (0 success, non-zero error) and documented. +- [ ] [P0] Clear error messages with next-step guidance. +- [ ] [P1] --help output tidy with copyable examples; consistent option naming. +- [ ] [P2] Optional --verbose honoring repo logging conventions. + - Files: core/src/AzureMcp.Cli/** (command wiring/help); areas/*/*/Commands/** (messages) + +### Ownership and maintainability +- [ ] [P0] Interface-first services (IDeployService, IQuotaService) with explicit DI lifetimes. +- [ ] [P1] Reusable parsing/validation helpers with unit tests. +- [ ] [P2] Lightweight README per area (deploy, quota) describing purpose and extension points. + - Files: areas/deploy/src/AzureMcp.Deploy/Services/**; areas/quota/src/AzureMcp.Quota/**; core/src/** (DI registration) + +### Quick P0 punch list +- [ ] Fix command descriptions/names to remove legacy terms. +- [ ] Tighten validation and error messages for raw-mcp-tool-input and quota inputs. +- [ ] Ensure STJ source-gen contexts cover all (de)serialized types; remove reflection paths. +- [ ] Add/complete unit tests per command and output contract tests. +- [ ] Update docs/azmcp-commands.md, add examples, and fix typos. +- [ ] Update CHANGELOG.md for new commands/features. +- [ ] Run Build-Local verification and CSpell; fix findings. +- [ ] Address CI failures across platforms until all green. From 0d2b7387d7ec4cc726596f80d0e955f83ab8da12 Mon Sep 17 00:00:00 2001 From: qianwens Date: Mon, 11 Aug 2025 11:59:29 +0800 Subject: [PATCH 32/56] fix the comments in code review report --- CHANGELOG.md | 11 +++++++++++ areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs | 10 +++++----- docs/azmcp-commands.md | 2 +- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 194cac67c..c7fcededa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ ## 0.5.4 (2025-08-07) +### Features Added + +- Added support for the following Azure Deploy operations and Azure Quota operations: [[#626](https://github.com/Azure/azure-mcp/pull/626)] + - `azmcp-deploy-app-logs-get` - Get logs from Azure applications deployed using azd. + - `azmcp-deploy-iac-rules-get` - Get Infrastructure as Code rules. + - `azmcp-deploy-pipeline-guidance-get` - Get guidance for creating CI/CD pipelines to provision Azure resources and deploy applications. + - `azmcp-deploy-plan-get` - Generate deployment plans to construct infrastructure and deploy applications on Azure. + - `azmcp-deploy-architecture-diagram-generate` - Generate Azure service architecture diagrams based on application topology. + - `azmcp-quota-region-availability-list` - List available Azure regions for specific resource types. + - `azmcp-quota-usage-check` - Check Azure resource usage and quota information for specific resource types and regions. + ### Bugs Fixed - Fixed subscription parameter handling across all Azure MCP service methods to consistently use `subscription` instead of `subscriptionId`, enabling proper support for both subscription IDs and subscription names. [[#877](https://github.com/Azure/azure-mcp/issues/877)] diff --git a/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs b/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs index 87234731c..470f96179 100644 --- a/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs +++ b/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs @@ -24,11 +24,11 @@ public void ConfigureServices(IServiceCollection services) public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactory) { var deploy = new CommandGroup("deploy", "Deploy commands for deploying applications to Azure, including sub commands: " - + "- plan_get: generates a deployment plan to construct the infrastructure and deploy the application on Azure. Agent should read its output and generate a deploy plan in '.azure/plan.copilotmd' for execution steps, recommended azure services based on the information agent detected from project. Before calling this tool, please scan this workspace to detect the services to deploy and their dependent services; " - + "- iac_rules_get: offers guidelines for creating Bicep/Terraform files to deploy applications on Azure; " - + "- app_logs_get: fetch logs from log analytics workspace for Container Apps, App Services, function apps that were deployed through azd; " - + "- pipeline_guidance_get: guidance to create a CI/CD pipeline which provision Azure resources and build and deploy applications to Azure; " - + "- architecture_diagram_generate: generates an azure service architecture diagram for the application based on the provided app topology; "); + + "- plan get: generates a deployment plan to construct the infrastructure and deploy the application on Azure. Agent should read its output and generate a deploy plan in '.azure/plan.copilotmd' for execution steps, recommended azure services based on the information agent detected from project. Before calling this tool, please scan this workspace to detect the services to deploy and their dependent services; " + + "- iac rules get: offers guidelines for creating Bicep/Terraform files to deploy applications on Azure; " + + "- app logs get: fetch logs from log analytics workspace for Container Apps, App Services, function apps that were deployed through azd; " + + "- pipeline guidance get: guidance to create a CI/CD pipeline which provision Azure resources and build and deploy applications to Azure; " + + "- architecture diagram generate: generates an azure service architecture diagram for the application based on the provided app topology; "); rootGroup.AddSubGroup(deploy); // Application-specific commands diff --git a/docs/azmcp-commands.md b/docs/azmcp-commands.md index d7b9a9e6b..790e31c79 100644 --- a/docs/azmcp-commands.md +++ b/docs/azmcp-commands.md @@ -899,7 +899,7 @@ azmcp quota usage check --subscription \ --resource-types # Get the available regions for the resources types -azmcp quota quota region availability list --subscription \ +azmcp quota region availability list --subscription \ --resource-types \ [--cognitive-service-model-name ] \ [--cognitive-service-model-version ] \ From 0083cc6992da3ad5f1ce086cfae3ce57a717fa06 Mon Sep 17 00:00:00 2001 From: qianwens Date: Mon, 11 Aug 2025 12:04:59 +0800 Subject: [PATCH 33/56] remove code review doc --- docs/PR-626-Code-Review.md | 269 ------- docs/PR-626-Final-Recommendations.md | 1014 -------------------------- 2 files changed, 1283 deletions(-) delete mode 100644 docs/PR-626-Code-Review.md delete mode 100644 docs/PR-626-Final-Recommendations.md diff --git a/docs/PR-626-Code-Review.md b/docs/PR-626-Code-Review.md deleted file mode 100644 index 505857312..000000000 --- a/docs/PR-626-Code-Review.md +++ /dev/null @@ -1,269 +0,0 @@ -# Code Review Report: PR #626 — Deploy and Quota Commands - -This report reviews PR #626 against the guidance in `docs/PR-626-Final-Recommendations.md` and repository standards. It summarizes what’s aligned, what’s missing, risks, and concrete next steps to reach compliance and improve maintainability. - -## Review checklist - -- [ ] Command groups follow hierarchical structure and consistent naming -- [ ] Command names use pattern (no hyphens) -- [ ] Files/namespaces reorganized per target structure -- [ ] Integration leverages existing extension services (az/azd) where applicable -- [ ] AOT/trimming safety validated (no RequiresDynamicCode violations) -- [ ] Uses System.Text.Json (no Newtonsoft) -- [ ] Tests updated and sufficient coverage -- [ ] Documentation updated (commands, migration notes, prompts) -- [ ] CHANGELOG and spelling checks updated - -## Findings - -### 1) Command structure and naming - -- Deploy area now uses hierarchical groups and verbs: - - deploy app logs get - - deploy infrastructure rules get - - deploy pipeline guidance get - - deploy plan get - - deploy architecture diagram generate -- Quota area uses hierarchical groups and verbs: - - quota usage check - - quota region availability list -- Registration uses CommandGroup with nested subgroups for both areas. Good. -- Minor issue: the top-level deploy CommandGroup description string still references legacy hyphenated names (plan_get, iac_rules_get, etc.). This is cosmetic but inconsistent with the new pattern. - -Status: Mostly PASS (fix description text). - -### 2) File/folder organization and namespaces - -- Deploy: Commands and Options are placed under App/Infrastructure/Pipeline/Plan/Architecture subfolders. Services and Templates folders added. JsonSourceGeneration context present. Good. -- Quota: Commands moved under Usage/ and Region/, Options split accordingly. Good. - -Status: PASS. - -### 3) Integration with existing extension commands - -- Guidance recommends reusing the existing extension (Az/Azd) services to avoid duplication and clarify ownership. Current DeployService calls a local AzdResourceLogService to fetch logs via workspace parsing + Monitor Query. There’s no explicit reuse of the extension Az/Azd command surface. -- The PR’s intent acknowledges that azd-app-logs is temporary until azd exposes a native command. That’s fine short-term, but we should still abstract this behind interfaces and consider delegating through the extension service to reduce duplication and future migrations. - -Status: PARTIAL. Needs refactor to consume extension services (IAzService/IAzdService) or document/ticket the temporary approach with deprecation plan. - -### 4) AOT/trimming safety - -- Projects declare true. -- Uses System.Text.Json source generation in Deploy (DeployJsonContext) and Quota (QuotaJsonContext). Good. -- YamlDotNet is used, but via low-level parser (events) rather than POCO deserialization. This reduces reflection/AOT risk, but we should still run the AOT analyzer script to confirm there are no warnings and consider linker descriptors if needed. -- Reflection is used to load embedded resources (GetManifestResourceStream). That’s typically safe with embedded resources; ensure resources are included (they are) and names are stable. - -Status: LIKELY PASS (verify with analyzer). Action item to run eng/scripts/Analyze-AOT-Compact.ps1 and address any findings. - -### 5) JSON library choice - -- System.Text.Json is used across new code. No Newtonsoft dependencies in these areas. Good. - -Status: PASS. - -### 6) Tests - -- Unit tests added for Quota commands (AvailabilityListCommandTests, CheckCommandTests) and Deploy command tests exist for the reorganized classes (LogsGetCommandTests, RulesGetCommandTests, GuidanceGetCommandTests, etc.). -- Tests validate parsing, happy paths, errors, and output shapes. Nice. - -Status: PASS (keep adding targeted edge cases; see gaps below). - -### 7) Documentation - -- docs/azmcp-commands.md updated to include Deploy/Quota sections. -- Issue: one example contains a duplicated group token: “azmcp quota quota region availability list”. -- E2E prompts updated; several questions remain unresolved in PR comments (e.g., value-add vs existing tools, pipeline setup guidance). Consider consolidating with migration notes from legacy hyphenated names and calling out new structure explicitly. - -Status: PARTIAL. Fix command typos and add a short “migration from legacy names” section. - -### 8) CHANGELOG and spelling - -- PR checklist flags CHANGELOG and spelling as incomplete. These need to be completed before merge. - -Status: FAIL (to be addressed). - -## Gaps and risks - -- Integration duplication: DeployService/AzdResourceLogService duplicates behavior that would better live behind extension services. Risk of divergence and confusion with azd MCP server effort. Mitigate by factoring through IAzService/IAzdService and marking the logs command as temporary. -- Documentation accuracy: Minor typos/conflicts can mislead users (e.g., duplicated “quota” token). Add migration notes to reduce confusion for users familiar with prior hyphenated commands. -- AOT/trimming: While likely safe, adding YamlDotNet warrants a quick AOT scan and consideration of linker configs if warnings appear. -- CI failures: Current PR pipeline shows failures for several platform builds (linux_x64, osx_x64, win_x64). Investigate before merge. - -## Targeted recommendations and next steps - -P0 (must do before merge) -- Fix deploy CommandGroup description to remove legacy hyphenated names and align with actual subcommands. -- Fix docs/azmcp-commands.md typos (“azmcp quota quota region availability list” → “azmcp quota region availability list”). -- Add CHANGELOG entry summarizing new areas (deploy/quota), command structure, and any breaking command name changes. -- Run spelling check: .\\eng\\common\\spelling\\Invoke-Cspell.ps1 and address findings. -- Investigate the CI failures on the PR (linux_x64, osx_x64, win_x64 jobs) and resolve. - -P1 (integration and maintainability) -- Abstract DeployService’s log retrieval to use extension services (IAzdService) where possible, or encapsulate current logic behind an interface to ease migration when azd exposes native logs. -- Consider adding a light project reference to the extension area if needed for reuse (as recommended), or explicitly document why it’s deferred. -- Add “migration notes” to docs explaining the new hierarchical verbs and mapping from legacy hyphenated names. -- Expand tests for: - - Architecture diagram: invalid/malformed raw-mcp-tool-input and large service graphs. - - Quota parsing: empty/whitespace resource-types; mixed casing; extreme list lengths. - -P2 (optional enhancements) -- Template system: Centralize all prompt content via TemplateService (you’ve started this); document template names and parameters; add unit tests for template retrieval. -- Performance: Consider caching region availability lookups and IaC rule templates where applicable. -- AOT verification: Add a short note to the PR description capturing AOT analysis results and any linker config changes (if needed). - -## Compliance matrix vs Final Recommendations - -- Command Groups (quota, deploy subgroups): Done. -- Command Structure Changes (verbs): Done; minor description text cleanup pending. -- Integration Strategy (reuse az/azd): Partially done; not yet wired to extension services. -- File/Folder Reorg: Done. -- Namespace Updates: Done. -- Project File Updates (embedded resources): Done. Extension project reference: Not added (consider per integration plan). -- Registration Updates: Done (areas registered in core setup). -- Template System: Implemented; continue consolidating prompts. -- Plan command scope: Current implementation returns a plan template; it no longer writes files directly. Aligned with guidance. - -## Quick quality gates snapshot - -- Build: PR pipeline shows failures on multiple x64 jobs (linux/osx/win). Needs investigation. -- Lint/Spelling: Spelling unchecked; run script and fix. -- Tests: New unit tests present; ensure they run in CI and are green after any fixes. -- Smoke/help: Verify `azmcp deploy --help` and `azmcp quota --help` show the expected hierarchy post-changes. - -## Appendix: Suggested documentation deltas - -- docs/azmcp-commands.md - - Fix: “azmcp quota quota region availability list” → “azmcp quota region availability list”. - - Add “Migration from legacy hyphenated names” table mapping plan-get → plan get, iac-rules-get → infrastructure rules get, etc. -- docs/new-command.md - - Include example of hierarchical registration via CommandGroup and guidance on naming. - ---- - -Completion summary -- The PR largely meets the architectural reorg, naming, and testability goals. The biggest remaining items are integration reuse (az/azd), small docs fixes, CHANGELOG/spelling, and CI stabilization. Addressing the P0/P1 items above should make this PR ready to merge. - -## Exhaustive merge-readiness checklist - -Note: Priority tags — [P0] must before merge, [P1] should before merge, [P2] nice-to-have. - -### Command design and UX -- [ ] [P0] Verify command hierarchy and verbs match guidance exactly: - - deploy app logs get - - deploy infrastructure rules get - - deploy pipeline guidance get - - deploy plan get - - deploy architecture diagram generate - - quota usage check - - quota region availability list -- [ ] [P0] Remove legacy/hyphenated names and outdated descriptions (e.g., DeploySetup group description). - - Files: areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs; areas/quota/src/AzureMcp.Quota/QuotaSetup.cs (verify help text) -- [ ] [P0] Ensure --help text is concise and consistent; clearly mark required vs optional options. -- [ ] [P1] Provide help examples for each command (typical + edge-case for raw-mcp-tool-input). -- [ ] [P1] Confirm output shapes and property casing are consistent and documented. - -### Options and input validation -- [ ] [P0] Validate raw-mcp-tool-input JSON: required fields present; unknown fields behavior defined; helpful errors. -- [ ] [P0] Validate quota inputs: region/resource types normalized; reject empty/invalid sets; friendly messages. -- [ ] [P0] Validate logs query time windows and limits; set sane defaults and max bounds. -- [ ] [P1] Add JSON schema snippets for raw-mcp-tool-input in docs and link from --help. -- [ ] [P2] Robust list parsers (comma/space/newline with trimming) + tests. - - Files: areas/deploy/src/AzureMcp.Deploy/Options/DeployOptionDefinitions.cs; areas/deploy/src/AzureMcp.Deploy/Options/App/LogsGetOptions.cs; areas/quota/src/AzureMcp.Quota/Commands/Usage/CheckCommand.cs; areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs - -### Code structure, AOT, and serialization -- [ ] [P0] Use primary constructors where applicable in new classes. -- [ ] [P0] Use System.Text.Json only; no Newtonsoft. -- [ ] [P0] Ensure all DTOs are covered by source-gen contexts (DeployJsonContext, QuotaJsonContext) in all (de)serializations. -- [ ] [P0] Prefer static members where possible; avoid reflection/dynamic not safe for AOT. -- [ ] [P0] Run AOT/trimming analysis; address warnings (preserve attributes/linker config if needed). -- [ ] [P1] Add JSON round-trip tests proving source-gen coverage. -- [ ] [P2] Enforce culture-invariant formatting/parsing (dates, numbers, casing). - - Files: areas/deploy/src/AzureMcp.Deploy/Commands/DeployJsonContext.cs; areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs - -### Services and Azure SDK usage -- [ ] [P0] Use IHttpClientFactory and reuse Azure SDK clients appropriately; avoid per-call instantiation. -- [ ] [P0] Use Azure SDK default retries/timeouts; avoid custom retries unless justified. -- [ ] [P0] Respect cloud/authority from environment; support sovereign clouds. -- [ ] [P0] Use TokenCredential correctly; do not accept/store secrets directly. -- [ ] [P1] Abstract log retrieval behind an interface and prefer routing via extension services (IAzService/IAzdService) to reduce duplication. -- [ ] [P2] Keep diagnostics minimal, opt-in, and scrub PII. - - Files: areas/deploy/src/AzureMcp.Deploy/Services/DeployService.cs; areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs - -### Templates, resources, and I/O -- [ ] [P0] Ensure embedded templates/rules are included and load via correct manifest names. -- [ ] [P0] Validate template outputs are non-null and meaningful; handle missing resource errors. -- [ ] [P1] Tests confirming presence and expected content of embedded resources. -- [ ] [P2] Cache static templates in-memory (thread-safe) to reduce I/O. - - Files: areas/deploy/src/AzureMcp.Deploy/Services/Templates/TemplateService.cs; areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs; areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs - -### Security and robustness -- [ ] [P0] Bound and sanitize inputs for diagram generation; warn or fail cleanly if payload exceeds safe URL length. -- [ ] [P0] Encode all URLs; avoid external calls based on untrusted input (Azure SDK excepted). -- [ ] [P1] Review YamlDotNet usage; handle malformed YAML with clear errors. -- [ ] [P1] Plumb CancellationToken through long-running operations (logs queries). -- [ ] [P2] Consider allowlist constraints for resource types/locations if applicable. - - Files: areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs; areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs - -### Testing -- [ ] [P0] Per-command tests (success + 1–2 error cases): - - deploy/app/logs/get (invalid YAML, empty resources, query timeout) - - deploy/infrastructure/rules/get (resource presence) - - deploy/plan/get (template present) - - deploy/architecture/diagram/generate (bad/large JSON, valid graph) - - quota/usage/check (invalid/empty resource types, mixed casing) - - quota/region/availability/list (filters, cognitive services variants) -- [ ] [P0] Tests to ensure JsonSerializerContext is used (no reflection fallback at runtime). -- [ ] [P0] Tests asserting output contracts (shape, casing, required properties). -- [ ] [P1] Integration tests with recorded fixtures/test proxy where feasible. -- [ ] [P1] Update E2E prompt tests with new commands and sample payloads. -- [ ] [P2] Concurrency and large-input perf smoke tests. - - Files (tests to extend/verify): areas/deploy/tests/AzureMcp.Deploy.UnitTests/**; areas/quota/tests/AzureMcp.Quota.UnitTests/**; core/tests/AzureMcp.Tests/** - -### Documentation -- [ ] [P0] Fix typos (e.g., docs/azmcp-commands.md “azmcp quota quota …” → “azmcp quota …”). -- [ ] [P0] Document each command: synopsis, options, example inputs/outputs, error cases, JSON schemas. -- [ ] [P0] Update CHANGELOG.md summarizing new areas/commands and notable behavior. -- [ ] [P1] Troubleshooting notes (auth issues, timeouts, payload too large for diagrams). -- [ ] [P2] Link to architecture decisions for diagram/templates. - - Files: docs/azmcp-commands.md; CHANGELOG.md; docs/new-command.md; docs/PR-626-Code-Review.md (this document) - -### Repo hygiene and engineering system -- [ ] [P0] One class/interface per file; remove dead code/unused usings; consistent naming. -- [ ] [P0] Ensure copyright headers; run header script. -- [ ] [P0] Run local verifications: - - ./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx - - .\eng\common\spelling\Invoke-Cspell.ps1 -- [ ] [P0] dotnet build of AzureMcp.sln passes cleanly; address warnings-as-errors. -- [ ] [P1] Run Analyze-AOT-Compact.ps1 and Analyze-Code.ps1; address issues. -- [ ] [P2] Verify package versions pinned in Directory.Packages.props match standards. - - Files: Directory.Packages.props; eng/scripts/*.ps1; eng/common/spelling/*; solution-wide - -### CI and cross-platform readiness -- [ ] [P0] Investigate and fix failing CI jobs (linux_x64, osx_x64, win_x64); reproduce locally as needed. -- [ ] [P0] Ensure tests are stable (not time/date/region dependent); deflake if needed. -- [ ] [P1] Validate trimming/AOT publish on CI; ensure any publish profiles succeed. -- [ ] [P2] Add light smoke validation for each new command in CI (mock/dry-run). - - Files/areas: eng/pipelines/**; failing job logs in GitHub Actions; areas/deploy/**; areas/quota/** - -### User experience polish -- [ ] [P0] Consistent exit codes (0 success, non-zero error) and documented. -- [ ] [P0] Clear error messages with next-step guidance. -- [ ] [P1] --help output tidy with copyable examples; consistent option naming. -- [ ] [P2] Optional --verbose honoring repo logging conventions. - - Files: core/src/AzureMcp.Cli/** (command wiring/help); areas/*/*/Commands/** (messages) - -### Ownership and maintainability -- [ ] [P0] Interface-first services (IDeployService, IQuotaService) with explicit DI lifetimes. -- [ ] [P1] Reusable parsing/validation helpers with unit tests. -- [ ] [P2] Lightweight README per area (deploy, quota) describing purpose and extension points. - - Files: areas/deploy/src/AzureMcp.Deploy/Services/**; areas/quota/src/AzureMcp.Quota/**; core/src/** (DI registration) - -### Quick P0 punch list -- [ ] Fix command descriptions/names to remove legacy terms. -- [ ] Tighten validation and error messages for raw-mcp-tool-input and quota inputs. -- [ ] Ensure STJ source-gen contexts cover all (de)serialized types; remove reflection paths. -- [ ] Add/complete unit tests per command and output contract tests. -- [ ] Update docs/azmcp-commands.md, add examples, and fix typos. -- [ ] Update CHANGELOG.md for new commands/features. -- [ ] Run Build-Local verification and CSpell; fix findings. -- [ ] Address CI failures across platforms until all green. diff --git a/docs/PR-626-Final-Recommendations.md b/docs/PR-626-Final-Recommendations.md deleted file mode 100644 index b217bc8f2..000000000 --- a/docs/PR-626-Final-Recommendations.md +++ /dev/null @@ -1,1014 +0,0 @@ -# PR #626 Final Recommendations - Deploy and Quota Commands (Test inprogress) - -## Executive Summary - -This document consolidates all analysis, feedback, and recommendations for PR #626 which introduces deployment and quota management commands to Azure MCP Server. Based on architectural review, standards compliance analysis, and stakeholder feedback, this document provides the definitive refactoring plan. - -## Table of Contents - -1. [Current State Analysis](#current-state-analysis) -2. [Final Architecture Recommendations](#final-architecture-recommendations) -3. [Command Structure Reorganization](#command-structure-reorganization) -4. [Integration with Existing Commands](#integration-with-existing-commands) -5. [Implementation Action Plan](#implementation-action-plan) -6. [Test Scenarios](#test-scenarios) -7. [Validation Criteria](#validation-criteria) - -## Current State Analysis - -### PR Overview -- **Files Added**: 105 files with 6,521 additions and 44 deletions -- **New Areas**: `deploy` and `quota` command areas -- **Current Commands**: 7 commands with hyphenated naming and flat registration - -### Standards Violations Identified -1. **Command Registration**: Uses flat `AddCommand()` instead of hierarchical `CommandGroup` pattern -2. **Command Naming**: Hyphenated names (`plan-get`, `iac-rules-get`) violate ` ` pattern -3. **Architecture**: Overlaps with existing `AzCommand` and `AzdCommand` in `areas/extension/` - -### Existing Extension Commands -The codebase contains: -- `areas/extension/src/AzureMcp.Extension/Commands/AzCommand.cs` - Full Azure CLI execution -- `areas/extension/src/AzureMcp.Extension/Commands/AzdCommand.cs` - Full AZD execution - -## Final Architecture Recommendations - -### Command Groups - -Organize tools into these command groups: - -1. **`quota`** - Resource quota checking and usage analysis -2. **`deploy azd`** - AZD-specific deployment tools -3. **`deploy az`** - Azure CLI-specific deployment tools -4. **`deploy diagrams`** - Architecture diagram generation - -### Command Structure Changes - -**From Current**: -```bash -azmcp deploy plan-get -azmcp deploy iac-rules-get -azmcp deploy azd-app-log-get -azmcp deploy cicd-pipeline-guidance-get -azmcp deploy architecture-diagram-generate -azmcp quota usage-get -azmcp quota available-region-list -``` - -**To Target**: -```bash -azmcp quota usage check -azmcp quota region availability list -azmcp deploy app logs get -azmcp deploy infrastructure rules get -azmcp deploy pipeline guidance get -azmcp deploy plan get -azmcp deploy architecture diagram generate -``` - -### Integration Strategy - -Integration with existing commands: -- **AZD Operations**: Use existing `azmcp extension azd` internally -- **Azure CLI Operations**: Use existing `azmcp extension az` internally -- **Value-Added Services**: PR commands provide structured guidance on top of base CLI - -## Command Structure Reorganization - -### Deploy Area Refactoring - -**File**: `areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs` - -**Target Structure**: -```csharp -public static void RegisterCommands(CommandGroup deploy) -{ - // Application-specific commands - var appGroup = new CommandGroup("app", "Application-specific deployment tools"); - appGroup.AddCommand("logs", new LogsGetCommand(...)); // app logs get - - // Infrastructure as Code commands - var infrastructureGroup = new CommandGroup("infrastructure", "Infrastructure as Code operations"); - infrastructureGroup.AddCommand("rules", new RulesGetCommand(...)); // infrastructure rules get - - // CI/CD Pipeline commands - var pipelineGroup = new CommandGroup("pipeline", "CI/CD pipeline operations"); - pipelineGroup.AddCommand("guidance", new GuidanceGetCommand(...)); // pipeline guidance get - - // Deployment planning commands - var planGroup = new CommandGroup("plan", "Deployment planning operations"); - planGroup.AddCommand("get", new GetCommand(...)); // plan get - - // Architecture diagram commands - var architectureGroup = new CommandGroup("architecture", "Architecture diagram operations"); - architectureGroup.AddCommand("diagram", new DiagramGenerateCommand(...)); // architecture diagram generate - - deploy.AddCommandGroup(appGroup); - deploy.AddCommandGroup(infrastructureGroup); - deploy.AddCommandGroup(pipelineGroup); - deploy.AddCommandGroup(planGroup); - deploy.AddCommandGroup(architectureGroup); -} -``` - -### Quota Area Refactoring - -**File**: `areas/quota/src/AzureMcp.Quota/QuotaSetup.cs` - -**Target Structure**: -```csharp -public static void RegisterCommands(CommandGroup quota) -{ - // Resource usage and quota operations - var usageGroup = new CommandGroup("usage", "Resource usage and quota operations"); - usageGroup.AddCommand("check", new CheckCommand(...)); // usage check - - // Region availability operations - var regionGroup = new CommandGroup("region", "Region availability operations"); - regionGroup.AddCommand("availability", new AvailabilityListCommand(...)); // region availability list - - quota.AddCommandGroup(usageGroup); - quota.AddCommandGroup(regionGroup); -} -``` - -### Command Name Property Updates - -**Changes Required**: -1. `PlanGetCommand.Name` → `"get"` (was `"plan-get"`) -2. `IaCRulesGetCommand.Name` → `"rules"` (was `"iac-rules-get"`) -3. `AzdAppLogGetCommand.Name` → `"logs"` (was `"azd-app-log-get"`) -4. `PipelineGenerateCommand.Name` → `"guidance"` (was `"cicd-pipeline-guidance-get"`) -5. `GenerateArchitectureDiagramCommand.Name` → `"diagram"` (was `"architecture-diagram-generate"`) -6. `UsageCheckCommand.Name` → `"check"` (was `"usage-get"`) -7. `RegionCheckCommand.Name` → `"availability"` (was `"available-region-list"`) - -## File and Folder Reorganization - -### Deploy Area File Structure Changes - -#### Current Structure: -``` -areas/deploy/src/AzureMcp.Deploy/ -├── Commands/ -│ ├── AzdAppLogGetCommand.cs -│ ├── GenerateArchitectureDiagramCommand.cs -│ ├── IaCRulesGetCommand.cs -│ ├── PipelineGenerateCommand.cs -│ └── PlanGetCommand.cs -├── Options/ -│ ├── AzdAppLogOptions.cs -│ ├── GenerateArchitectureDiagramOptions.cs -│ ├── IaCRulesOptions.cs -│ ├── PipelineGenerateOptions.cs -│ └── PlanGetOptions.cs -└── Services/ - └── [various service files] -``` - -#### Target Structure (Hierarchical Organization): -``` -areas/deploy/src/AzureMcp.Deploy/ -├── Commands/ -│ ├── App/ -│ │ └── LogsGetCommand.cs (renamed from AzdAppLogGetCommand.cs) -│ ├── Infrastructure/ -│ │ └── RulesGetCommand.cs (renamed from IaCRulesGetCommand.cs) -│ ├── Pipeline/ -│ │ └── GuidanceGetCommand.cs (renamed from PipelineGenerateCommand.cs) -│ ├── Plan/ -│ │ └── GetCommand.cs (renamed from PlanGetCommand.cs) -│ └── Architecture/ -│ └── DiagramGenerateCommand.cs (renamed from GenerateArchitectureDiagramCommand.cs) -├── Options/ -│ ├── App/ -│ │ └── LogsGetOptions.cs (renamed from AzdAppLogOptions.cs) -│ ├── Infrastructure/ -│ │ └── RulesGetOptions.cs (renamed from IaCRulesOptions.cs) -│ ├── Pipeline/ -│ │ └── GuidanceGetOptions.cs (renamed from PipelineGenerateOptions.cs) -│ ├── Plan/ -│ │ └── GetOptions.cs (renamed from PlanGetOptions.cs) -│ └── Architecture/ -│ └── DiagramGenerateOptions.cs (renamed from GenerateArchitectureDiagramOptions.cs) -├── Templates/ (new directory) -│ ├── InfrastructureRulesTemplate.md -│ ├── PipelineGuidanceTemplate.md -│ └── DeploymentPlanTemplate.md -└── Services/ - ├── ITemplateService.cs (new interface) - ├── TemplateService.cs (new implementation) - └── [existing service files] -``` - -### Quota Area File Structure Changes - -#### Current Structure: -``` -areas/quota/src/AzureMcp.Quota/ -├── Commands/ -│ ├── RegionCheckCommand.cs -│ └── UsageCheckCommand.cs -├── Options/ -│ ├── RegionCheckOptions.cs -│ └── UsageCheckOptions.cs -└── Services/ - └── [various service files] -``` - -#### Target Structure (Hierarchical Organization): -``` -areas/quota/src/AzureMcp.Quota/ -├── Commands/ -│ ├── Usage/ -│ │ └── CheckCommand.cs (renamed from UsageCheckCommand.cs) -│ └── Region/ -│ └── AvailabilityListCommand.cs (renamed from RegionCheckCommand.cs) -├── Options/ -│ ├── Usage/ -│ │ └── CheckOptions.cs (renamed from UsageCheckOptions.cs) -│ └── Region/ -│ └── AvailabilityListOptions.cs (renamed from RegionCheckOptions.cs) -└── Services/ - └── [existing service files] -``` - -### Detailed File Rename Mapping - -#### Deploy Area Command Files: -| Current File | New File | New Class Name | Purpose | -|--------------|----------|----------------|---------| -| `Commands/AzdAppLogGetCommand.cs` | `Commands/App/LogsGetCommand.cs` | `LogsGetCommand` | Get logs from AZD-deployed applications | -| `Commands/IaCRulesGetCommand.cs` | `Commands/Infrastructure/RulesGetCommand.cs` | `RulesGetCommand` | Get Infrastructure as Code rules and guidelines | -| `Commands/PipelineGenerateCommand.cs` | `Commands/Pipeline/GuidanceGetCommand.cs` | `GuidanceGetCommand` | Get CI/CD pipeline guidance and configuration | -| `Commands/PlanGetCommand.cs` | `Commands/Plan/GetCommand.cs` | `GetCommand` | Generate Azure deployment plans | -| `Commands/GenerateArchitectureDiagramCommand.cs` | `Commands/Architecture/DiagramGenerateCommand.cs` | `DiagramGenerateCommand` | Generate Azure architecture diagrams | - -#### Deploy Area Option Files: -| Current File | New File | New Class Name | Purpose | -|--------------|----------|----------------|---------| -| `Options/AzdAppLogOptions.cs` | `Options/App/LogsGetOptions.cs` | `LogsGetOptions` | Options for app log retrieval | -| `Options/IaCRulesOptions.cs` | `Options/Infrastructure/RulesGetOptions.cs` | `RulesGetOptions` | Options for IaC rules retrieval | -| `Options/PipelineGenerateOptions.cs` | `Options/Pipeline/GuidanceGetOptions.cs` | `GuidanceGetOptions` | Options for pipeline guidance | -| `Options/PlanGetOptions.cs` | `Options/Plan/GetOptions.cs` | `GetOptions` | Options for deployment planning | -| `Options/GenerateArchitectureDiagramOptions.cs` | `Options/Architecture/DiagramGenerateOptions.cs` | `DiagramGenerateOptions` | Options for diagram generation | - -#### Quota Area Command Files: -| Current File | New File | New Class Name | Purpose | -|--------------|----------|----------------|---------| -| `Commands/UsageCheckCommand.cs` | `Commands/Usage/CheckCommand.cs` | `CheckCommand` | Check Azure resource usage and quotas | -| `Commands/RegionCheckCommand.cs` | `Commands/Region/AvailabilityListCommand.cs` | `AvailabilityListCommand` | List available regions for resource types | - -#### Quota Area Option Files: -| Current File | New File | New Class Name | Purpose | -|--------------|----------|----------------|---------| -| `Options/UsageCheckOptions.cs` | `Options/Usage/CheckOptions.cs` | `CheckOptions` | Options for usage checking | -| `Options/RegionCheckOptions.cs` | `Options/Region/AvailabilityListOptions.cs` | `AvailabilityListOptions` | Options for region availability listing | - -### Test File Updates Required - -#### Deploy Area Test Files: -``` -areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/ -├── App/ -│ └── LogsGetCommandTests.cs (update from AzdAppLogGetCommandTests.cs) -├── Infrastructure/ -│ └── RulesGetCommandTests.cs (update from IaCRulesGetCommandTests.cs) -├── Pipeline/ -│ └── GuidanceGetCommandTests.cs (update from PipelineGenerateCommandTests.cs) -├── Plan/ -│ └── GetCommandTests.cs (update from PlanGetCommandTests.cs) -└── Architecture/ - └── DiagramGenerateCommandTests.cs (update from GenerateArchitectureDiagramCommandTests.cs) -``` - -#### Quota Area Test Files: -``` -areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/ -├── Usage/ -│ └── CheckCommandTests.cs (update from UsageCheckCommandTests.cs) -└── Region/ - └── AvailabilityListCommandTests.cs (update from RegionCheckCommandTests.cs) -``` - -### Namespace Updates Required - -#### Deploy Area Namespaces: -- `AzureMcp.Deploy.Commands.App` for application-specific commands (logs) -- `AzureMcp.Deploy.Commands.Infrastructure` for Infrastructure as Code commands -- `AzureMcp.Deploy.Commands.Pipeline` for CI/CD pipeline commands -- `AzureMcp.Deploy.Commands.Plan` for deployment planning commands -- `AzureMcp.Deploy.Commands.Architecture` for architecture diagram commands -- `AzureMcp.Deploy.Options.App` for application command options -- `AzureMcp.Deploy.Options.Infrastructure` for infrastructure command options -- `AzureMcp.Deploy.Options.Pipeline` for pipeline command options -- `AzureMcp.Deploy.Options.Plan` for planning command options -- `AzureMcp.Deploy.Options.Architecture` for architecture command options -- `AzureMcp.Deploy.Services` for template and other services - -#### Quota Area Namespaces: -- `AzureMcp.Quota.Commands.Usage` for usage-related commands -- `AzureMcp.Quota.Commands.Region` for region-related commands -- `AzureMcp.Quota.Options.Usage` for usage command options -- `AzureMcp.Quota.Options.Region` for region command options - -### Project File Updates - -#### Deploy Area Project File: -```xml - - - - - - - - - -``` - -#### Quota Area Project File: -```xml - - - - -``` - -### Registration File Updates - -#### DeploySetup.cs: -- Update `using` statements for new namespaces -- Update command registration to use `CommandGroup` hierarchy -- Register new `ITemplateService` and extension services - -#### QuotaSetup.cs: -- Update `using` statements for new namespaces -- Update command registration to use `CommandGroup` hierarchy -- Register extension services - -## Integration with Existing Commands - -### Internal Service Integration - -**Add Extension Dependencies**: -```xml - - -``` - -**Service Registration**: -```csharp -// In area setup ConfigureServices methods -services.AddTransient(); -services.AddTransient(); -``` - -### Command Implementation Updates - -**Example: AzdAppLogGetCommand using existing AzdCommand**: -```csharp -public sealed class AzdAppLogGetCommand( - ILogger logger, - IAzdService azdService) : SubscriptionCommand() -{ - public override string Name => "logs"; - - protected override async Task ExecuteAsync(AzdAppLogOptions options, CancellationToken cancellationToken) - { - // Use existing AZD service to get environment info - var envResult = await azdService.ExecuteAsync("env list", options.WorkspaceFolder); - - // Use existing AZD service to get logs - var logsResult = await azdService.ExecuteAsync($"monitor logs --environment {options.AzdEnvName}", options.WorkspaceFolder); - - // Add value by filtering and formatting logs for specific app types - var filteredLogs = FilterLogsForAppTypes(logsResult.Output); - - return McpResult.Success(filteredLogs); - } -} -``` - -## Prompt Template Consolidation - -### Template System Enhancement - -**Objective**: Replace dynamic prompt construction with embedded markdown templates. - -**Implementation**: -1. Create `areas/deploy/src/AzureMcp.Deploy/Templates/` directory -2. Extract prompts to markdown files: - - `AzdRulesTemplate.md` - - `PipelineGuidanceTemplate.md` - - `DeploymentPlanTemplate.md` -3. Create injectable `ITemplateService` interface -4. Add embedded resources to project file - -**Template Service Interface**: -```csharp -public interface ITemplateService -{ - Task GetTemplateAsync(string templateName, object parameters = null); -} -``` - -### Deployment Planning Separation - -**Split PlanGetCommand Responsibilities**: -- **Keep**: Project analysis and service recommendations -- **Add**: Next steps with specific tool commands -- **Remove**: Direct file generation (.azure/plan.copilotmd) - -**Next Steps Response**: -```csharp -public class PlanAnalysisResult -{ - public string[] RecommendedServices { get; set; } - public string[] NextStepCommands { get; set; } // Specific azmcp commands to run - public string DeploymentStrategy { get; set; } - public string[] RequiredTools { get; set; } -} -``` - -## Implementation Action Plan - -### Phase 1: Priority 0 (Must Complete First) - -#### 1.1 Command Registration Refactoring -- **Priority**: P0 -- **Files**: `DeploySetup.cs`, `QuotaSetup.cs` -- **Action**: Replace flat registration with `CommandGroup` hierarchy -- **Validation**: Commands accessible via new structure - -#### 1.2 Command Name Updates -- **Priority**: P0 -- **Files**: All command class files -- **Action**: Update `Name` properties to single verbs -- **Validation**: Unit tests pass with new names - -#### 1.3 Build Verification -- **Priority**: P0 -- **Action**: `dotnet build AzureMcp.sln` -- **Expected**: Zero compilation errors - -### Phase 2: Integration and Enhancement - -#### 2.1 Extension Service Integration -- **Priority**: P1 -- **Action**: Add project references and service injection -- **Validation**: Commands use existing Az/Azd services internally - -#### 2.2 Test Updates -- **Priority**: P1 -- **Action**: Update unit and live tests for new structure -- **Validation**: All tests pass - -### Phase 3: Optional Enhancements - -#### 3.1 Template System -- **Priority**: P2 -- **Action**: Extract prompts to embedded resources -- **Validation**: Template loading works correctly - -#### 3.2 Documentation -- **Priority**: P2 -- **Action**: Update `azmcp-commands.md` and `new-command.md` -- **Validation**: Documentation reflects new structure - -## Test Scenarios - -### Comprehensive Manual Test Cases (30 Scenarios) - -#### Command Registration and Help Tests [PASS] - -1. **Deploy Command Group Help** - - **Command**: `azmcp deploy --help` - - **Expected**: Shows deploy subcommands (app, infrastructure, pipeline, plan, architecture) - - **Validation**: All 5 subcommand groups are listed - -2. **Quota Command Group Help** - - **Command**: `azmcp quota --help` - - **Expected**: Shows quota subcommands (usage, region) - - **Validation**: Both subcommand groups are listed - -3. **Deploy App Commands Help** - - **Command**: `azmcp deploy app --help` - - **Expected**: Shows app subcommands (logs) - - **Validation**: logs command is available - -4. **Deploy Infrastructure Commands Help** - - **Command**: `azmcp deploy infrastructure --help` - - **Expected**: Shows infrastructure subcommands (rules) - - **Validation**: rules command is available - -5. **Deploy Pipeline Commands Help** - - **Command**: `azmcp deploy pipeline --help` - - **Expected**: Shows pipeline subcommands (guidance) - - **Validation**: guidance command is available - -#### Quota Command Tests [PASS] - -6. **Quota Usage Check - Valid Subscription** - - **Command**: `azmcp quota usage check --subscription 12345678-1234-1234-1234-123456789abc --region eastus --resource-type Microsoft.Compute/virtualMachines` - - **Expected**: Returns quota usage information for the subscription - - **Validation**: JSON output with quota data - -7. **Quota Usage Check - Invalid Subscription** - - **Command**: `azmcp quota usage check --subscription invalid-sub-id` - - **Expected**: Returns authentication or validation error - - **Validation**: subscription is not required - -8. **Region Availability List - Specific Resource** - - **Command**: `azmcp quota region availability list --resource-type Microsoft.Compute/virtualMachines` - - **Expected**: Returns list of regions where VMs are available - - **Validation**: JSON array of region names - -9. **Region Availability List - All Resources** - - **Command**: `azmcp quota region availability list` - - **Expected**: Returns general region availability information - - **Validation**: Clear error message about missing resource type - - -#### Deploy App Commands Tests [PASS: Covered in automation] - -11. **App Logs Get - Valid AZD Environment** - - **Command**: `azmcp deploy app logs get --workspace-folder ./myapp --azd-env-name dev` - - **Expected**: Returns application logs from AZD-deployed environment - - **Validation**: Log entries with timestamps - -12. **App Logs Get - Invalid Environment** - - **Command**: `azmcp deploy app logs get --workspace-folder ./myapp --azd-env-name nonexistent` - - **Expected**: Returns error about environment not found - - **Validation**: Clear error message - -13. **App Logs Get - No AZD Project** - - **Command**: `azmcp deploy app logs get --workspace-folder ./empty-folder` - - **Expected**: Returns error about missing AZD project - - **Validation**: Error indicates no azure.yaml found - -14. **App Logs Get with Service Filter** - - **Command**: `azmcp deploy app logs get --workspace-folder ./myapp --azd-env-name dev --service-name api` - - **Expected**: Returns logs filtered to specific service - - **Validation**: Logs only from specified service - -#### Deploy Infrastructure Commands Tests [PASS] - -15. **Infrastructure Rules Get - Bicep Project** - - **Command**: `azmcp deploy infrastructure rules get ` - - **Expected**: Returns Bicep-specific IaC rules and recommendations - - **Validation**: Rules specific to Bicep templates - -16. **Infrastructure Rules Get - Terraform Project** - - **Command**: `azmcp deploy infrastructure rules get` - - **Expected**: Returns Terraform-specific IaC rules and recommendations - - **Validation**: Rules specific to Terraform configuration - -#### Deploy Pipeline Commands Tests [PASS] - -19. **Pipeline Guidance Get - GitHub Project** - - **Command**: `azmcp deploy pipeline guidance get --workspace-folder ./github-project` - - **Expected**: Returns GitHub Actions CI/CD pipeline guidance - - **Validation**: GitHub-specific workflow recommendations - -20. **Pipeline Guidance Get - Azure DevOps Project** - - **Command**: `azmcp deploy pipeline guidance get --workspace-folder ./azdo-project` - - **Expected**: Returns Azure DevOps pipeline guidance - - **Validation**: Azure Pipelines YAML recommendations - -21. **Pipeline Guidance Get - No VCS Project** - - **Command**: `azmcp deploy pipeline guidance get --workspace-folder ./no-git` - - **Expected**: Returns general CI/CD guidance - - **Validation**: Platform-agnostic recommendations - -#### Deploy Plan Commands Tests - -22. **Plan Get - .NET Project** [Pass] - - **Command**: `azmcp deploy plan get --raw-mcp-tool-input {}` - - **Expected**: Returns deployment plan specific to .NET applications - - **Validation**: Recommendations for App Service or Container Apps - - - -#### Deploy Architecture Commands Testsv [PASS] - -26. **Architecture Diagram Generate - Simple App** - - **Command**: `azmcp deploy architecture diagram generate --workspace-folder ./simple-app` - - **Expected**: Returns Mermaid diagram for simple application architecture - - **Validation**: Valid Mermaid syntax with basic components - -27. **Architecture Diagram Generate - Microservices** - - **Command**: `azmcp deploy architecture diagram generate --workspace-folder ./microservices` - - **Expected**: Returns complex Mermaid diagram with multiple services - - **Validation**: Comprehensive diagram with service relationships - -28. **Architecture Diagram Generate with Custom Options** - - **Command**: `azmcp deploy architecture diagram generate --workspace-folder ./myapp --include-networking --include-security` - - **Expected**: Returns detailed diagram including network and security components - - **Validation**: Enhanced diagram with additional layers - -#### Error Handling and Edge Cases [PASS] - -29. **Invalid Command Structure (Legacy Format)** - - **Command**: `azmcp deploy plan-get --workspace-folder ./myapp` - - **Expected**: Command not found error - - **Validation**: Clear error indicating command format change - -30. **Missing Required Parameters** - - **Command**: `azmcp quota usage check` - - **Expected**: Returns error about missing required subscription parameter - - **Validation**: Clear parameter requirement message - -### Integration and Extension Service Tests - -#### Extension Integration Tests [PASS] (bellow command has no duplicated command in azd/az) - -31. **AZD Service Integration** - - **Scenario**: Verify deploy commands use existing AzdCommand internally - - **Command**: `azmcp deploy app logs get --workspace-folder ./azd-project` - - **Expected**: Command successfully executes - - **Validation**: No duplication of AZD functionality - -32. **Azure CLI Service Integration** - - **Scenario**: Verify quota commands use existing AzCommand internally - - **Command**: `azmcp quota usage check --subscription-id ` - - **Expected**: Command successfully executes Azure CLI operations via extension - - **Validation**: Structured output from Azure CLI data - -### Performance and Reliability Tests [PASS] - -33. **Large Project Analysis** - - **Command**: `azmcp deploy plan get --workspace-folder ./large-enterprise-app` - - **Expected**: Command completes within reasonable time (< 30 seconds) - - **Validation**: Response time and memory usage within limits, the analysis is performed by agent, the tool response quickly with the plan template. - -34. **Concurrent Command Execution** - - **Scenario**: Run multiple commands simultaneously - - **Commands**: Multiple instances of quota and deploy commands - - **Expected**: All commands complete successfully without conflicts - - **Validation**: No resource contention or errors - -### Authentication and Authorization Tests [PASS] - -35. **Unauthenticated Azure Access** - - **Command**: `azmcp quota usage check --subscription-id ` - - **Expected**: Clear authentication error when not logged into Azure - - **Validation**: Helpful error message with login instructions - -36. **Insufficient Permissions** - - **Command**: `azmcp quota usage check --subscription-id ` - - **Expected**: Permission denied error with clear explanation - - **Validation**: Specific permission requirements listed - -### Template and Output Format Tests - -37. **JSON Output Format** - - **Command**: `azmcp quota usage check --subscription-id --output json` - - **Expected**: Well-formed JSON response - - **Validation**: Valid JSON structure - -38. **Markdown Output Format** - - **Command**: `azmcp deploy plan get --workspace-folder ./myapp --output markdown` - - **Expected**: Formatted markdown response - - **Validation**: Proper markdown structure with headers and lists - -### Cross-Platform Tests - -39. **Windows PowerShell Execution** - - **Scenario**: Execute commands in Windows PowerShell environment - - **Command**: All deploy and quota commands - - **Expected**: Commands execute successfully on Windows - - **Validation**: No platform-specific errors - -40. **Linux/macOS Execution** - - **Scenario**: Execute commands in bash/zsh environment - - **Command**: All deploy and quota commands - - **Expected**: Commands execute successfully on Unix-like systems - - **Validation**: Cross-platform compatibility - -### Copilot Natural Language Test Prompts - -The following prompts can be used with GitHub Copilot or VS Code Copilot to test the deployment and quota functionality through natural language interactions. These prompts validate that the MCP tools are properly integrated and accessible through conversational interfaces. - -#### Quota Management Prompts - -1. **Basic Quota Check** - - **Prompt**: "Check my Azure quota usage for subscription 12345678-1234-1234-1234-123456789abc" - - **Expected**: Copilot uses `azmcp quota usage check` command - - **Validation**: Returns structured quota information - - **Test Result**: Pass - - **Test Observation**: Agent call the tool to check quota usage for the resource types that inferred from the project. - It would call this tool multiple times for different regions as it is not specified in the prompt. - -2. **Region Availability Query** - - **Prompt**: "What regions are available for virtual machines in Azure?" - - **Expected**: Copilot uses `azmcp quota region availability list` command - - **Validation**: Returns list of regions with VM availability - - **Test Result**: Pass - -3. **Resource-Specific Quota** - - **Prompt**: "Check quota limits for compute resources in my Azure subscription" - - **Expected**: Copilot uses quota commands with appropriate filters - - **Validation**: Returns compute-specific quota data - - **Test Result**: Pass - -4. **Regional Capacity Planning** - - **Prompt**: "I need to deploy 100 VMs - which Azure regions have capacity?" - - **Expected**: Copilot uses region availability and quota commands - - **Validation**: Provides capacity recommendations - - **Test Result**: Pass - -#### Deployment Planning Prompts - -5. **Application Deployment Planning** - - **Prompt**: "Help me plan deployment for my .NET web application to Azure" - - **Expected**: Copilot uses `azmcp deploy plan get` command - - **Validation**: Returns deployment recommendations for .NET apps - - **Model**: Claude Sonnet 4 - - **Project Context**: ESHOPWEB project with .NET. Bicep files are present. - - **Test Result**: Pass - - **Test Observation**: - Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get - Terminals called during the plan execution: az account show, azd auth login --check-status, azd env list, azd init --environment eshop-dev, azd env set AZURE_LOCATION eastus, azd provision --preview, azd up - -6. **Microservices Architecture Planning** - - **Prompt**: "Generate a deployment plan for my microservices application" - - **Expected**: Copilot uses deployment planning tools - - **Validation**: Returns multi-service deployment strategy - - **Model**: Claude Sonnet 4 - - **Project Context**: EXAMPLE-VOTING-APP project with .NET, python. Three micro services. Bicep files not present. - - **Test Result**: Pass - - **Test Observation**: - Plan generated correctly with the microservices defined as container apps in one environment. - Tools called during the plan creation: deploy plan get, iac rules get - Tools called during the plan execution: iac rules get - Terminals called during the plan execution: azd version, azd init, azd env list, azd up - -7. **Infrastructure as Code Guidance** - - **Prompt**: "What are the best practices for Bicep templates in my project?" - - **Expected**: Copilot uses `azmcp deploy infrastructure rules get` command - - **Validation**: Returns Bicep-specific recommendations - - **Test Result**: Pass - - **Test Observation**: - Tools called: bicepschema get, bestpractices get, deploy iac rules get - Agent aggregated the rules from the Bicep schema best practices and iac rule, returned them in a single response. - -8. **CI/CD Pipeline Setup** - - **Prompt**: "Help me set up CI/CD for my GitHub project deploying to Azure" - - **Expected**: Copilot uses `azmcp deploy pipeline guidance get` command - - **Validation**: Returns GitHub Actions workflow recommendations - - **Test Result**: Pass - - **Test Observation**: - Tools called: deploy pipeline guidance get - Terminals called: azd pipeline config - `azd pipeline config` error: resolving bicep parameters file: substituting environment variables for environmentName: unable to parse variable name - Agent failed to resolve this error, so it switch to its own solution to setup pipeline with az command - -#### Architecture and Visualization Prompts - -9. **Architecture Diagram Generation** - - **Prompt**: "Create an architecture diagram for my application deployment" - - **Expected**: Copilot uses `azmcp deploy architecture diagram generate` command - - **Validation**: Returns Mermaid diagram - - **Test Result**: Pass - -10. **Complex System Visualization** - - **Prompt**: "Generate a detailed architecture diagram including networking and security" - - **Expected**: Copilot uses diagram generation with enhanced options - - **Validation**: Returns comprehensive diagram - - **Test Result**: Fail - - **Test Observation**: `azmcp deploy architecture diagram generate` cannot handle complex architecture with networking and security components. So it returned a simple diagram without those components. - Added tool description to tell agent that it cannot handle complex architecture with networking and security components. - -#### Application Monitoring Prompts - -11. **Application Log Analysis** - - **Prompt**: "Show me logs from my AZD-deployed application in the dev environment" - - **Expected**: Copilot uses `azmcp deploy app logs get` command - - **Validation**: Returns filtered application logs - - **Test Result**: Pass - -12. **Service-Specific Monitoring** - - **Prompt**: "Get logs for the API service in my containerized application" - - **Expected**: Copilot uses app logs command with service filtering - - **Validation**: Returns service-specific log data - - **Test Result**: Pass - -#### Multi-Step Workflow Prompts - -13. **End-to-End Deployment Workflow** - - **Prompt**: "I have a new Python app - help me deploy it to Azure from scratch" - - **Expected**: Copilot uses multiple commands (plan, infrastructure, pipeline) - - **Validation**: Provides step-by-step deployment guidance - - **Test Result**: Pass - - **Test Observation**: - Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get - -14. **Capacity Planning Workflow** - - **Prompt**: "Plan Azure resources for a high-traffic e-commerce application" - - **Expected**: Copilot uses quota, planning, and architecture tools - - **Validation**: Comprehensive capacity and architecture recommendations - - **Test Result**: Pass - - **Test Observation**: - Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get - Agent designed an azure architecture with Azure Front Door and Azure CDN for high traffic, the backend is using ACA. - -15. **Troubleshooting Workflow** - - **Prompt**: "My Azure deployment is failing - help me diagnose the issue" - - **Expected**: Copilot uses logs, quota, and diagnostic commands - - **Validation**: Systematic troubleshooting approach - - **Test Result**: Pass - -#### Technology-Specific Prompts - -16. **Node.js Application Deployment** - - **Prompt**: "Deploy my Node.js Express app to Azure with best practices" - - **Expected**: Copilot provides Node.js-specific deployment plan - - **Validation**: Appropriate Azure service recommendations - - **Test Result**: Pass - -17. **Container Deployment Strategy** - - **Prompt**: "What's the best way to deploy my Docker containers to Azure?" - - **Expected**: Copilot recommends container-specific Azure services - - **Validation**: Container-optimized deployment strategy - - **Test Result**: Pass - - **Test Observation**: - Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get, documentation search - Agent recommended aca, app service for container, aks and compared the differences. - - -18. **Database Integration Planning** - - **Prompt**: "Plan deployment including a PostgreSQL database for my web app" - - **Expected**: Copilot includes database services in deployment plan - - **Validation**: Integrated database and application deployment - - **Test Result**: Pass - - **Test Observation**: - Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get, deploy iac rules get - Agent created a deployment plan with PostgreSQL database and recommended using Azure Database for PostgreSQL Flexible Server. - -#### Error Handling and Edge Case Prompts - -19. **Invalid Project Context** - - **Prompt**: "Generate deployment plan for this empty folder" - - **Expected**: Copilot handles missing project context gracefully - - **Validation**: Appropriate error handling and guidance - - **Test Result**: Pass - - **Test Observation**:Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get - Agent created a deployment plan with general recommendations, even though the folder is empty. - -20. **Authentication Issues** - - **Prompt**: "Check my Azure quotas (when not authenticated)" - - **Expected**: Copilot provides clear authentication guidance - - **Validation**: Helpful error messages and login instructions - - **Test Result**: Pass - -#### Advanced Integration Prompts - -21. **Cross-Service Integration** - - **Prompt**: "Plan deployment for this project, use function for the backend service, use app service for the frontend service" - - **Expected**: Copilot coordinates multiple Azure services - - **Validation**: Integrated multi-service architecture - - **Test Result**: Pass - - **Test Observation**: Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get - Agent created a deployment plan with Azure Functions for backend and Azure App Service for frontend. - -22. **Compliance and Security Focus** - - **Prompt**: "Deploy my healthcare app with HIPAA compliance requirements" - - **Expected**: Copilot emphasizes security and compliance features - - **Validation**: Security-focused deployment recommendations - - **Test Result**: Pass - - **Test Observation**: Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get - Agent created a deployment plan with HIPAA Requirements: Data encryption, access audit trails, secure communication, identity management - -23. **Cost Optimization Planning** - - **Prompt**: "Plan cost-effective Azure deployment for my startup application" - - **Expected**: Copilot recommends cost-optimized services and configurations - - **Validation**: Budget-conscious deployment strategy - - **Test Result**: Pass - - **Test Observation**: Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get - Agent created a deployment plan with ACA consumption plan. - -24. **Scaling Strategy Development** - - **Prompt**: "Plan Azure deployment that can scale from 1000 to 1 million users" - - **Expected**: Copilot provides scalable architecture recommendations - - **Validation**: Auto-scaling and performance considerations - - **Test Result**: Pass - - **Test Observation**: Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get - Agent created a deployment plan with AKS and its Horizontal Pod Autoscaler (HPA) Configuration. - -25. **Multi-Environment Strategy** - - **Prompt**: "Set up dev, staging, and production environments for my app" - - **Expected**: Copilot provides multi-environment deployment strategy - - **Validation**: Environment-specific configurations and pipelines - - **Test Result**: Pass - - **Test Observation**: Tools called: best practices get, azd learn, azd config show, azd init - -#### Integration Testing Prompts - -26. **Tool Integration Validation** - - **Prompt**: "Use Azure CLI to check my subscription then plan deployment" - - **Expected**: Copilot seamlessly integrates existing AZ commands with new tools - - **Validation**: No duplication of CLI functionality - - **Test Result**: Pass - - **Test Observation**: Tools called az account show, subscription list, deploy plan get - -27. **AZD Integration Testing** - - **Prompt**: "Get logs from my azd-deployed application and plan next deployment" - - **Expected**: Copilot uses existing AZD integration effectively - - **Validation**: Proper integration with existing AZD commands - - **Test Result**: Pass - -28. **Command Discovery Testing** - - **Prompt**: "What deployment tools are available in this Azure MCP server?" - - **Expected**: Copilot lists available deployment and quota commands - - **Validation**: Complete tool discovery and explanation - - **Test Result**: Pass - - **Test Observation**: Tools called: deploy learn, azd learn - Agent provided a list of azd commands and the deploy plan tool as specialized deployment tool. - -#### Performance and Reliability Prompts - -29. **Large Project Handling** - - **Prompt**: "Analyze deployment requirements for this enterprise monorepo" - - **Expected**: Copilot handles complex project analysis efficiently - - **Validation**: Reasonable response time and comprehensive analysis - - **Test Result**: Pass - - **Test Observation**: Tools called: deploy plan get, bestpractices get, deploy iac rules get - Agent is responsible to analyze the monorepo, the tools responded fast with the static template for the target service. - -30. **Concurrent Operation Testing** - - **Prompt**: "Check quotas while generating architecture diagram and planning deployment" - - **Expected**: Copilot handles multiple concurrent operations - - **Validation**: All operations complete successfully without conflicts - - **Test Result**: Pass - - **Test Observation**: Tools called: subscription list, quota available region list, quota usage get, deploy architecture diagram generate - - -### Expected Copilot Behavior Patterns - -When testing with these prompts, validate that Copilot: - -1. **Command Selection**: Chooses appropriate azmcp commands based on user intent -2. **Parameter Handling**: Correctly infers or prompts for required parameters -3. **Error Handling**: Provides helpful guidance when commands fail -4. **Integration**: Uses existing extension commands when appropriate -5. **Output Processing**: Formats and explains command results clearly -6. **Follow-up Actions**: Suggests logical next steps after command execution -7. **Context Awareness**: Considers project structure and environment in recommendations - -## Validation Criteria - -### Build and Test Requirements - -- [ ] `dotnet build AzureMcp.sln` succeeds with zero errors -- [ ] All unit tests pass -- [ ] Live tests pass (when Azure credentials available) -- [ ] CLI help commands work for all new structures - -### Command Structure Compliance - -- [ ] All commands follow ` ` pattern -- [ ] No hyphenated command names -- [ ] Hierarchical `CommandGroup` registration used -- [ ] Command names are single verbs (`get`, `list`, `generate`, etc.) - -### Integration Requirements - -- [ ] Deploy commands use existing Extension services internally -- [ ] No duplication of Az/Azd CLI functionality -- [ ] Value-added services provide structured guidance -- [ ] Clear differentiation between guided vs direct CLI access - -### Documentation Standards - -- [ ] All commands documented in `azmcp-commands.md` -- [ ] Examples show new command structure -- [ ] Migration notes for changed command names -- [ ] Integration patterns documented in `new-command.md` - -## Post-Implementation Considerations - -### Future Architecture Evolution - -1. **AZD MCP Server Migration**: When Azure Developer CLI creates their own MCP server, evaluate migrating AZD-specific tools -2. **Template System Enhancement**: Expand template system for more dynamic content generation -3. **Cross-Area Integration**: Explore integration between deploy and quota areas -4. **Performance Optimization**: Cache quota information and template loading - -### Monitoring and Metrics - -1. **Command Usage**: Track which new commands are most/least used -2. **Error Patterns**: Monitor common failure scenarios for improvement -3. **Integration Success**: Measure successful extension service integration -4. **User Feedback**: Collect feedback on new command structure - -## Conclusion - -This refactoring plan addresses identified standards violations while preserving the deployment and quota management capabilities introduced in PR #626. The changes include: - -1. **Proper Command Structure**: Hierarchical `CommandGroup` registration following established patterns -2. **Standard Naming**: ` ` pattern without hyphens -3. **Integration**: Leverage existing Extension commands to avoid duplication -4. **Value-Added Services**: Focus on structured guidance and templates rather than raw CLI access - -The implementation will proceed with the priority 0 items first to ensure build stability, followed by integration enhancements and optional improvements. This approach maintains the capabilities while aligning with repository standards and architectural patterns. From f3cd8a625db3cb1ec613645b4e0e331583914d13 Mon Sep 17 00:00:00 2001 From: qianwens Date: Mon, 11 Aug 2025 13:19:44 +0800 Subject: [PATCH 34/56] fix test failure --- .../Commands/ToolLoading/CommandFactoryToolLoaderTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/tests/AzureMcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs b/core/tests/AzureMcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs index 1eb52ed9c..0e7f86e3e 100644 --- a/core/tests/AzureMcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs +++ b/core/tests/AzureMcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs @@ -305,7 +305,7 @@ public async Task GetsToolsWithRawMcpInputOption() Assert.NotEmpty(result.Tools); var tool = result.Tools.FirstOrDefault(tool => - tool.Name.Equals("deploy_architecture-diagram-generate", StringComparison.OrdinalIgnoreCase)); + tool.Name.Equals("deploy_architecture_diagram_generate", StringComparison.OrdinalIgnoreCase)); Assert.NotNull(tool); Assert.NotNull(tool.Name); Assert.NotNull(tool.Description!); From 4c69f5a423deb12d15057c318182ba6d0152e087 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 11 Aug 2025 10:24:20 -0700 Subject: [PATCH 35/56] Add code review report for PR #626 addressing deploy and quota commands - Summarized findings and compliance with architectural guidelines - Documented command structure, integration issues, and AOT safety - Provided targeted recommendations and next steps for improvements - Included exhaustive merge-readiness checklist and quick quality gates snapshot - Suggested documentation deltas and compliance matrix against final recommendations --- docs/PR-626-Action-Plan.md | 147 ++++++ docs/PRReview.md | 1025 ++++++++++++++++++++++++++++++++++++ docs/PRReviewNotes.md | 288 ++++++++++ 3 files changed, 1460 insertions(+) create mode 100644 docs/PR-626-Action-Plan.md create mode 100644 docs/PRReview.md create mode 100644 docs/PRReviewNotes.md diff --git a/docs/PR-626-Action-Plan.md b/docs/PR-626-Action-Plan.md new file mode 100644 index 000000000..6daa6b64b --- /dev/null +++ b/docs/PR-626-Action-Plan.md @@ -0,0 +1,147 @@ +# PR #626 — Deploy and Quota: Action Plan and Next Steps + +This plan consolidates concrete follow-ups to take PR #626 from “draft” to “merge-ready,” based on the repository standards and the findings in PR review notes. + +## Goals and success criteria + +- Consistent, hierarchical command UX reflected across code, tests, and docs +- AOT/trimming and cloud-agnostic compliance preserved (no RequiresDynamicCode; sovereign cloud aware) +- Clean docs (no legacy names), CHANGELOG complete, spelling/build checks clean +- Clear path to reuse az/azd extension services; current temporary logic abstracted + +## P0 (must complete before merge) + +- Naming, descriptions, and docs + - [ ] CHANGELOG: switch examples to hierarchical CLI usage (e.g., `azmcp deploy plan get`, `azmcp quota region availability list`), optionally keep MCP tool ids in a separate subsection + - [ ] Remove any legacy/hyphenated names from command group descriptions and help + - Files: + - DeploySetup.cs: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs#L26-L31 + - QuotaSetup.cs: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs#L23-L27 + - [ ] docs/azmcp-commands.md: ensure examples match current hierarchy; fix duplicated token cases. + - Quota usage example (approx lines): https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/docs/azmcp-commands.md#L897-L905 + - Quota region availability example (approx lines): https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/docs/azmcp-commands.md#L902-L908 + - Deploy section header (approx line): https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/docs/azmcp-commands.md#L909 + - Deploy app logs example (approx lines): https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/docs/azmcp-commands.md#L924-L929 + +- Repo hygiene and gates + - [ ] Run spelling: `./eng/common/spelling/Invoke-Cspell.ps1` and fix findings + - [ ] Run local verification: `./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx` and fix issues + - [ ] Ensure `dotnet build AzureMcp.sln` passes cleanly (warnings-as-errors respected) + +- Robustness and platform compliance + - [ ] Add `CancellationToken` plumbing to long-running operations (logs, region/usage queries) and propagate from commands to services + - [ ] Replace `Console.WriteLine` usages with `ILogger` (structured, leveled logging) + - [ ] Replace static `HttpClient` with `IHttpClientFactory` via DI for direct REST calls + - [ ] Ensure sovereign cloud support: avoid hard-coded `https://management.azure.com`; prefer ARM SDK or derive authority from `ArmEnvironment` for PostgreSQL usage checker + - [ ] Verify consistent exit codes and clear, actionable error messages + +## P1 (should complete for maintainability) + +- Integration and abstraction + - [ ] Introduce an interface (e.g., `IAppLogService`/`IAzdLogService`) and put current logs implementation behind it + - [ ] Add a short deprecation note in code/docs indicating intent to delegate to `azd` native logs when available; plan to route via extension service (e.g., `IAzdService`) + +- Tests and schemas + - [ ] Diagram command: add tests for malformed/invalid `raw-mcp-tool-input` and oversize payload handling (safe URL limits) + - [ ] Quota commands: add tests for empty/whitespace resource types, mixed casing, and very long lists + - [ ] Add JSON round-trip tests to prove STJ source-gen coverage (no reflection fallback) + +- Documentation polish + - [ ] Per-command help examples (include one example with `raw-mcp-tool-input`) + - [ ] Troubleshooting notes (auth, timeouts, diagram URL length) + +## P2 (nice to have) + +- Performance and caching + - [ ] Cache region availability results per subscription/provider (short TTL) to reduce redundant queries + - [ ] Cache embedded templates in `TemplateService` + +- UX and contracts + - [ ] Optional `--verbose` flag following repo logging conventions + - [ ] Document output contracts (shape, casing) and link JSON schemas in docs + +## File-level edits (suggested targets) + +- Descriptions and registration + - `areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs` – group description/help text cleanup; ensure hierarchical verbs in help + - `areas/quota/src/AzureMcp.Quota/QuotaSetup.cs` – confirm concise help and subgroup descriptions + +- Quota services (robustness/cloud) + - `areas/quota/src/AzureMcp.Quota/Services/Util/*.cs` + - Replace `Console.WriteLine` with `ILogger` + - Introduce `CancellationToken` parameters + - Switch static `HttpClient` to `IHttpClientFactory` + - Remove hard-coded management endpoint; prefer ARM SDK or environment-derived authority + - AzureUsageChecker.cs: hardcoded authority and static HttpClient + - Token scope: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs#L68 + - Static HttpClient: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs#L53 + - Console.WriteLine usages: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs#L86-L169 + - PostgreSQLUsageChecker.cs: hardcoded https://management.azure.com + - Request URL: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs#L14-L15 + +- Deploy logs (integration seam) + - `areas/deploy/src/AzureMcp.Deploy/Services/*` – introduce `IAppLogService` (or `IAzdLogService`), adapt current implementation behind interface; prepare to delegate to extension service when available + - AzdResourceLogService.cs + - Entry method: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs#L12-L18 + - AzdAppLogRetriever usage: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs#L24 + - AzdAppLogRetriever.cs + - Type and initializer: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdAppLogRetriever.cs#L11-L31 + - QueryAppLogsAsync switch cases: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdAppLogRetriever.cs#L66-L116 + - TemplateService.cs + - Embedded template loader: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/deploy/src/AzureMcp.Deploy/Services/Templates/TemplateService.cs#L14-L48 + +- Documentation + - `docs/azmcp-commands.md` – hierarchical examples + troubleshooting + - `CHANGELOG.md` – hierarchical CLI usage; optionally list MCP tool ids separately + - JSON source-gen contexts (AOT): + - QuotaJsonContext.cs: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs#L12-L28 + +## AOT/trimming and cloud checks + +- [ ] Run `eng/scripts/Analyze-AOT-Compact.ps1`; resolve any warnings (linker config if needed) +- [ ] Ensure only System.Text.Json is used and DTOs are covered by source-gen contexts +- [ ] Confirm usage of Azure SDK defaults (retries/timeouts) and respect cloud/authority from environment (sovereign-ready) + +## Validation checklist (green-before-merge) + +- Build: `dotnet build` and `./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx` pass +- Spelling: `./eng/common/spelling/Invoke-Cspell.ps1` clean +- Tests: unit + live tests pass, including new edge cases +- Help/smoke: `azmcp deploy --help` and `azmcp quota --help` show expected hierarchy; examples are copyable and correct +- Docs: CHANGELOG and azmcp-commands updated + +## Optional runbook (local) + +> The following commands are optional references when validating locally on Windows PowerShell. + +```powershell +# Build + verify +./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx + +# Spelling +./eng/common/spelling/Invoke-Cspell.ps1 + +# Dotnet build +dotnet build ./AzureMcp.sln +``` + +## Ownership and tracking + +- Create or link tracking issues for: + - Logs abstraction + future delegation to `azd` + - Sovereign-cloud compliance in direct REST usage (if any remain after refactor) + - New tests (diagram/edge cases, quota parsing, JSON round-trip) +- Convert the checklists above into PR tasks or repo issues as appropriate. + +### Follow-up Issue Creation (P1/P2) + +For every P1 or P2 item in this action plan that is not completed in PR #626: + +- Create a GitHub issue titled: "[P1|P2] : " +- Labels: `area/deploy` or `area/quota` (and others as appropriate), `priority/P1` or `priority/P2`, and `PR/626-followup` +- Body: problem statement, acceptance criteria, links to exact files/lines and to this document, owner, and due date +- Cross-link in PR #626 and check off the corresponding item here when done + +--- + +Completion definition: All P0 items checked, validation checklist green, and at least the P1 “logs abstraction” in place with a deprecation note for the temporary implementation. diff --git a/docs/PRReview.md b/docs/PRReview.md new file mode 100644 index 000000000..66196ee05 --- /dev/null +++ b/docs/PRReview.md @@ -0,0 +1,1025 @@ +# PR #626 Final Recommendations - Deploy and Quota Commands (Test inprogress) + +## Executive Summary + +This document consolidates all analysis, feedback, and recommendations for PR #626 which introduces deployment and quota management commands to Azure MCP Server. Based on architectural review, standards compliance analysis, and stakeholder feedback, this document provides the definitive refactoring plan. + +## Table of Contents + +1. [Current State Analysis](#current-state-analysis) +2. [Final Architecture Recommendations](#final-architecture-recommendations) +3. [Command Structure Reorganization](#command-structure-reorganization) +4. [Integration with Existing Commands](#integration-with-existing-commands) +5. [Implementation Action Plan](#implementation-action-plan) +6. [Test Scenarios](#test-scenarios) +7. [Validation Criteria](#validation-criteria) + +> Tracking: For any P1 or P2 item not completed in this PR, file a follow-up GitHub issue (see "Follow-up Issue Creation" below) so we do not lose scope after merge. + +## Current State Analysis + +### PR Overview +- **Files Added**: 105 files with 6,521 additions and 44 deletions +- **New Areas**: `deploy` and `quota` command areas +- **Current Commands**: 7 commands with hyphenated naming and flat registration + +### Standards Violations Identified +1. **Command Registration**: Uses flat `AddCommand()` instead of hierarchical `CommandGroup` pattern +2. **Command Naming**: Hyphenated names (`plan-get`, `iac-rules-get`) violate ` ` pattern +3. **Architecture**: Overlaps with existing `AzCommand` and `AzdCommand` in `areas/extension/` + +### Existing Extension Commands +The codebase contains: +- `areas/extension/src/AzureMcp.Extension/Commands/AzCommand.cs` - Full Azure CLI execution +- `areas/extension/src/AzureMcp.Extension/Commands/AzdCommand.cs` - Full AZD execution + +## Final Architecture Recommendations + +### Command Groups + +Organize tools into these command groups: + +1. **`quota`** - Resource quota checking and usage analysis +2. **`deploy azd`** - AZD-specific deployment tools +3. **`deploy az`** - Azure CLI-specific deployment tools +4. **`deploy diagrams`** - Architecture diagram generation + +### Command Structure Changes + +**From Current**: +```bash +azmcp deploy plan-get +azmcp deploy iac-rules-get +azmcp deploy azd-app-log-get +azmcp deploy cicd-pipeline-guidance-get +azmcp deploy architecture-diagram-generate +azmcp quota usage-get +azmcp quota available-region-list +``` + +**To Target**: +```bash +azmcp quota usage check +azmcp quota region availability list +azmcp deploy app logs get +azmcp deploy infrastructure rules get +azmcp deploy pipeline guidance get +azmcp deploy plan get +azmcp deploy architecture diagram generate +``` + +### Integration Strategy + +Integration with existing commands: +- **AZD Operations**: Use existing `azmcp extension azd` internally +- **Azure CLI Operations**: Use existing `azmcp extension az` internally +- **Value-Added Services**: PR commands provide structured guidance on top of base CLI + +## Command Structure Reorganization + +### Deploy Area Refactoring + +**File**: `areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs` + +**Target Structure**: +```csharp +public static void RegisterCommands(CommandGroup deploy) +{ + // Application-specific commands + var appGroup = new CommandGroup("app", "Application-specific deployment tools"); + appGroup.AddCommand("logs", new LogsGetCommand(...)); // app logs get + + // Infrastructure as Code commands + var infrastructureGroup = new CommandGroup("infrastructure", "Infrastructure as Code operations"); + infrastructureGroup.AddCommand("rules", new RulesGetCommand(...)); // infrastructure rules get + + // CI/CD Pipeline commands + var pipelineGroup = new CommandGroup("pipeline", "CI/CD pipeline operations"); + pipelineGroup.AddCommand("guidance", new GuidanceGetCommand(...)); // pipeline guidance get + + // Deployment planning commands + var planGroup = new CommandGroup("plan", "Deployment planning operations"); + planGroup.AddCommand("get", new GetCommand(...)); // plan get + + // Architecture diagram commands + var architectureGroup = new CommandGroup("architecture", "Architecture diagram operations"); + architectureGroup.AddCommand("diagram", new DiagramGenerateCommand(...)); // architecture diagram generate + + deploy.AddCommandGroup(appGroup); + deploy.AddCommandGroup(infrastructureGroup); + deploy.AddCommandGroup(pipelineGroup); + deploy.AddCommandGroup(planGroup); + deploy.AddCommandGroup(architectureGroup); +} +``` + +### Quota Area Refactoring + +**File**: `areas/quota/src/AzureMcp.Quota/QuotaSetup.cs` + +**Target Structure**: +```csharp +public static void RegisterCommands(CommandGroup quota) +{ + // Resource usage and quota operations + var usageGroup = new CommandGroup("usage", "Resource usage and quota operations"); + usageGroup.AddCommand("check", new CheckCommand(...)); // usage check + + // Region availability operations + var regionGroup = new CommandGroup("region", "Region availability operations"); + regionGroup.AddCommand("availability", new AvailabilityListCommand(...)); // region availability list + + quota.AddCommandGroup(usageGroup); + quota.AddCommandGroup(regionGroup); +} +``` + +### Command Name Property Updates + +**Changes Required**: +1. `PlanGetCommand.Name` → `"get"` (was `"plan-get"`) +2. `IaCRulesGetCommand.Name` → `"rules"` (was `"iac-rules-get"`) +3. `AzdAppLogGetCommand.Name` → `"logs"` (was `"azd-app-log-get"`) +4. `PipelineGenerateCommand.Name` → `"guidance"` (was `"cicd-pipeline-guidance-get"`) +5. `GenerateArchitectureDiagramCommand.Name` → `"diagram"` (was `"architecture-diagram-generate"`) +6. `UsageCheckCommand.Name` → `"check"` (was `"usage-get"`) +7. `RegionCheckCommand.Name` → `"availability"` (was `"available-region-list"`) + +## File and Folder Reorganization + +### Deploy Area File Structure Changes + +#### Current Structure: +``` +areas/deploy/src/AzureMcp.Deploy/ +├── Commands/ +│ ├── AzdAppLogGetCommand.cs +│ ├── GenerateArchitectureDiagramCommand.cs +│ ├── IaCRulesGetCommand.cs +│ ├── PipelineGenerateCommand.cs +│ └── PlanGetCommand.cs +├── Options/ +│ ├── AzdAppLogOptions.cs +│ ├── GenerateArchitectureDiagramOptions.cs +│ ├── IaCRulesOptions.cs +│ ├── PipelineGenerateOptions.cs +│ └── PlanGetOptions.cs +└── Services/ + └── [various service files] +``` + +#### Target Structure (Hierarchical Organization): +``` +areas/deploy/src/AzureMcp.Deploy/ +├── Commands/ +│ ├── App/ +│ │ └── LogsGetCommand.cs (renamed from AzdAppLogGetCommand.cs) +│ ├── Infrastructure/ +│ │ └── RulesGetCommand.cs (renamed from IaCRulesGetCommand.cs) +│ ├── Pipeline/ +│ │ └── GuidanceGetCommand.cs (renamed from PipelineGenerateCommand.cs) +│ ├── Plan/ +│ │ └── GetCommand.cs (renamed from PlanGetCommand.cs) +│ └── Architecture/ +│ └── DiagramGenerateCommand.cs (renamed from GenerateArchitectureDiagramCommand.cs) +├── Options/ +│ ├── App/ +│ │ └── LogsGetOptions.cs (renamed from AzdAppLogOptions.cs) +│ ├── Infrastructure/ +│ │ └── RulesGetOptions.cs (renamed from IaCRulesOptions.cs) +│ ├── Pipeline/ +│ │ └── GuidanceGetOptions.cs (renamed from PipelineGenerateOptions.cs) +│ ├── Plan/ +│ │ └── GetOptions.cs (renamed from PlanGetOptions.cs) +│ └── Architecture/ +│ └── DiagramGenerateOptions.cs (renamed from GenerateArchitectureDiagramOptions.cs) +├── Templates/ (new directory) +│ ├── InfrastructureRulesTemplate.md +│ ├── PipelineGuidanceTemplate.md +│ └── DeploymentPlanTemplate.md +└── Services/ + ├── ITemplateService.cs (new interface) + ├── TemplateService.cs (new implementation) + └── [existing service files] +``` + +### Quota Area File Structure Changes + +#### Current Structure: +``` +areas/quota/src/AzureMcp.Quota/ +├── Commands/ +│ ├── RegionCheckCommand.cs +│ └── UsageCheckCommand.cs +├── Options/ +│ ├── RegionCheckOptions.cs +│ └── UsageCheckOptions.cs +└── Services/ + └── [various service files] +``` + +#### Target Structure (Hierarchical Organization): +``` +areas/quota/src/AzureMcp.Quota/ +├── Commands/ +│ ├── Usage/ +│ │ └── CheckCommand.cs (renamed from UsageCheckCommand.cs) +│ └── Region/ +│ └── AvailabilityListCommand.cs (renamed from RegionCheckCommand.cs) +├── Options/ +│ ├── Usage/ +│ │ └── CheckOptions.cs (renamed from UsageCheckOptions.cs) +│ └── Region/ +│ └── AvailabilityListOptions.cs (renamed from RegionCheckOptions.cs) +└── Services/ + └── [existing service files] +``` + +### Detailed File Rename Mapping + +#### Deploy Area Command Files: +| Current File | New File | New Class Name | Purpose | +|--------------|----------|----------------|---------| +| `Commands/AzdAppLogGetCommand.cs` | `Commands/App/LogsGetCommand.cs` | `LogsGetCommand` | Get logs from AZD-deployed applications | +| `Commands/IaCRulesGetCommand.cs` | `Commands/Infrastructure/RulesGetCommand.cs` | `RulesGetCommand` | Get Infrastructure as Code rules and guidelines | +| `Commands/PipelineGenerateCommand.cs` | `Commands/Pipeline/GuidanceGetCommand.cs` | `GuidanceGetCommand` | Get CI/CD pipeline guidance and configuration | +| `Commands/PlanGetCommand.cs` | `Commands/Plan/GetCommand.cs` | `GetCommand` | Generate Azure deployment plans | +| `Commands/GenerateArchitectureDiagramCommand.cs` | `Commands/Architecture/DiagramGenerateCommand.cs` | `DiagramGenerateCommand` | Generate Azure architecture diagrams | + +#### Deploy Area Option Files: +| Current File | New File | New Class Name | Purpose | +|--------------|----------|----------------|---------| +| `Options/AzdAppLogOptions.cs` | `Options/App/LogsGetOptions.cs` | `LogsGetOptions` | Options for app log retrieval | +| `Options/IaCRulesOptions.cs` | `Options/Infrastructure/RulesGetOptions.cs` | `RulesGetOptions` | Options for IaC rules retrieval | +| `Options/PipelineGenerateOptions.cs` | `Options/Pipeline/GuidanceGetOptions.cs` | `GuidanceGetOptions` | Options for pipeline guidance | +| `Options/PlanGetOptions.cs` | `Options/Plan/GetOptions.cs` | `GetOptions` | Options for deployment planning | +| `Options/GenerateArchitectureDiagramOptions.cs` | `Options/Architecture/DiagramGenerateOptions.cs` | `DiagramGenerateOptions` | Options for diagram generation | + +#### Quota Area Command Files: +| Current File | New File | New Class Name | Purpose | +|--------------|----------|----------------|---------| +| `Commands/UsageCheckCommand.cs` | `Commands/Usage/CheckCommand.cs` | `CheckCommand` | Check Azure resource usage and quotas | +| `Commands/RegionCheckCommand.cs` | `Commands/Region/AvailabilityListCommand.cs` | `AvailabilityListCommand` | List available regions for resource types | + +#### Quota Area Option Files: +| Current File | New File | New Class Name | Purpose | +|--------------|----------|----------------|---------| +| `Options/UsageCheckOptions.cs` | `Options/Usage/CheckOptions.cs` | `CheckOptions` | Options for usage checking | +| `Options/RegionCheckOptions.cs` | `Options/Region/AvailabilityListOptions.cs` | `AvailabilityListOptions` | Options for region availability listing | + +### Test File Updates Required + +#### Deploy Area Test Files: +``` +areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/ +├── App/ +│ └── LogsGetCommandTests.cs (update from AzdAppLogGetCommandTests.cs) +├── Infrastructure/ +│ └── RulesGetCommandTests.cs (update from IaCRulesGetCommandTests.cs) +├── Pipeline/ +│ └── GuidanceGetCommandTests.cs (update from PipelineGenerateCommandTests.cs) +├── Plan/ +│ └── GetCommandTests.cs (update from PlanGetCommandTests.cs) +└── Architecture/ + └── DiagramGenerateCommandTests.cs (update from GenerateArchitectureDiagramCommandTests.cs) +``` + +#### Quota Area Test Files: +``` +areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/ +├── Usage/ +│ └── CheckCommandTests.cs (update from UsageCheckCommandTests.cs) +└── Region/ + └── AvailabilityListCommandTests.cs (update from RegionCheckCommandTests.cs) +``` + +### Namespace Updates Required + +#### Deploy Area Namespaces: +- `AzureMcp.Deploy.Commands.App` for application-specific commands (logs) +- `AzureMcp.Deploy.Commands.Infrastructure` for Infrastructure as Code commands +- `AzureMcp.Deploy.Commands.Pipeline` for CI/CD pipeline commands +- `AzureMcp.Deploy.Commands.Plan` for deployment planning commands +- `AzureMcp.Deploy.Commands.Architecture` for architecture diagram commands +- `AzureMcp.Deploy.Options.App` for application command options +- `AzureMcp.Deploy.Options.Infrastructure` for infrastructure command options +- `AzureMcp.Deploy.Options.Pipeline` for pipeline command options +- `AzureMcp.Deploy.Options.Plan` for planning command options +- `AzureMcp.Deploy.Options.Architecture` for architecture command options +- `AzureMcp.Deploy.Services` for template and other services + +#### Quota Area Namespaces: +- `AzureMcp.Quota.Commands.Usage` for usage-related commands +- `AzureMcp.Quota.Commands.Region` for region-related commands +- `AzureMcp.Quota.Options.Usage` for usage command options +- `AzureMcp.Quota.Options.Region` for region command options + +### Project File Updates + +#### Deploy Area Project File: +```xml + + + + + + + + + +``` + +#### Quota Area Project File: +```xml + + + + +``` + +### Registration File Updates + +#### DeploySetup.cs: +- Update `using` statements for new namespaces +- Update command registration to use `CommandGroup` hierarchy +- Register new `ITemplateService` and extension services + +#### QuotaSetup.cs: +- Update `using` statements for new namespaces +- Update command registration to use `CommandGroup` hierarchy +- Register extension services + +## Integration with Existing Commands + +### Internal Service Integration + +**Add Extension Dependencies**: +```xml + + +``` + +**Service Registration**: +```csharp +// In area setup ConfigureServices methods +services.AddTransient(); +services.AddTransient(); +``` + +### Command Implementation Updates + +**Example: AzdAppLogGetCommand using existing AzdCommand**: +```csharp +public sealed class AzdAppLogGetCommand( + ILogger logger, + IAzdService azdService) : SubscriptionCommand() +{ + public override string Name => "logs"; + + protected override async Task ExecuteAsync(AzdAppLogOptions options, CancellationToken cancellationToken) + { + // Use existing AZD service to get environment info + var envResult = await azdService.ExecuteAsync("env list", options.WorkspaceFolder); + + // Use existing AZD service to get logs + var logsResult = await azdService.ExecuteAsync($"monitor logs --environment {options.AzdEnvName}", options.WorkspaceFolder); + + // Add value by filtering and formatting logs for specific app types + var filteredLogs = FilterLogsForAppTypes(logsResult.Output); + + return McpResult.Success(filteredLogs); + } +} +``` + +## Prompt Template Consolidation + +### Template System Enhancement + +**Objective**: Replace dynamic prompt construction with embedded markdown templates. + +**Implementation**: +1. Create `areas/deploy/src/AzureMcp.Deploy/Templates/` directory +2. Extract prompts to markdown files: + - `AzdRulesTemplate.md` + - `PipelineGuidanceTemplate.md` + - `DeploymentPlanTemplate.md` +3. Create injectable `ITemplateService` interface +4. Add embedded resources to project file + +**Template Service Interface**: +```csharp +public interface ITemplateService +{ + Task GetTemplateAsync(string templateName, object parameters = null); +} +``` + +### Deployment Planning Separation + +**Split PlanGetCommand Responsibilities**: +- **Keep**: Project analysis and service recommendations +- **Add**: Next steps with specific tool commands +- **Remove**: Direct file generation (.azure/plan.copilotmd) + +**Next Steps Response**: +```csharp +public class PlanAnalysisResult +{ + public string[] RecommendedServices { get; set; } + public string[] NextStepCommands { get; set; } // Specific azmcp commands to run + public string DeploymentStrategy { get; set; } + public string[] RequiredTools { get; set; } +} +``` + +## Implementation Action Plan + +### Phase 1: Priority 0 (Must Complete First) + +#### 1.1 Command Registration Refactoring +- **Priority**: P0 +- **Files**: `DeploySetup.cs`, `QuotaSetup.cs` +- **Action**: Replace flat registration with `CommandGroup` hierarchy +- **Validation**: Commands accessible via new structure + +#### 1.2 Command Name Updates +- **Priority**: P0 +- **Files**: All command class files +- **Action**: Update `Name` properties to single verbs +- **Validation**: Unit tests pass with new names + +#### 1.3 Build Verification +- **Priority**: P0 +- **Action**: `dotnet build AzureMcp.sln` +- **Expected**: Zero compilation errors + +### Phase 2: Integration and Enhancement + +#### 2.1 Extension Service Integration +- **Priority**: P1 +- **Action**: Add project references and service injection +- **Validation**: Commands use existing Az/Azd services internally + +#### 2.2 Test Updates +- **Priority**: P1 +- **Action**: Update unit and live tests for new structure +- **Validation**: All tests pass + +### Phase 3: Optional Enhancements + +#### 3.1 Template System +- **Priority**: P2 +- **Action**: Extract prompts to embedded resources +- **Validation**: Template loading works correctly + +#### 3.2 Documentation +- **Priority**: P2 +- **Action**: Update `azmcp-commands.md` and `new-command.md` +- **Validation**: Documentation reflects new structure + +## Test Scenarios + +### Comprehensive Manual Test Cases (30 Scenarios) + +#### Command Registration and Help Tests [PASS] + +1. **Deploy Command Group Help** + - **Command**: `azmcp deploy --help` + - **Expected**: Shows deploy subcommands (app, infrastructure, pipeline, plan, architecture) + - **Validation**: All 5 subcommand groups are listed + +2. **Quota Command Group Help** + - **Command**: `azmcp quota --help` + - **Expected**: Shows quota subcommands (usage, region) + - **Validation**: Both subcommand groups are listed + +3. **Deploy App Commands Help** + - **Command**: `azmcp deploy app --help` + - **Expected**: Shows app subcommands (logs) + - **Validation**: logs command is available + +4. **Deploy Infrastructure Commands Help** + - **Command**: `azmcp deploy infrastructure --help` + - **Expected**: Shows infrastructure subcommands (rules) + - **Validation**: rules command is available + +5. **Deploy Pipeline Commands Help** + - **Command**: `azmcp deploy pipeline --help` + - **Expected**: Shows pipeline subcommands (guidance) + - **Validation**: guidance command is available + +#### Quota Command Tests [PASS] + +6. **Quota Usage Check - Valid Subscription** + - **Command**: `azmcp quota usage check --subscription 12345678-1234-1234-1234-123456789abc --region eastus --resource-type Microsoft.Compute/virtualMachines` + - **Expected**: Returns quota usage information for the subscription + - **Validation**: JSON output with quota data + +7. **Quota Usage Check - Invalid Subscription** + - **Command**: `azmcp quota usage check --subscription invalid-sub-id` + - **Expected**: Returns authentication or validation error + - **Validation**: subscription is not required + +8. **Region Availability List - Specific Resource** + - **Command**: `azmcp quota region availability list --resource-type Microsoft.Compute/virtualMachines` + - **Expected**: Returns list of regions where VMs are available + - **Validation**: JSON array of region names + +9. **Region Availability List - All Resources** + - **Command**: `azmcp quota region availability list` + - **Expected**: Returns general region availability information + - **Validation**: Clear error message about missing resource type + + +#### Deploy App Commands Tests [PASS: Covered in automation] + +11. **App Logs Get - Valid AZD Environment** + - **Command**: `azmcp deploy app logs get --workspace-folder ./myapp --azd-env-name dev` + - **Expected**: Returns application logs from AZD-deployed environment + - **Validation**: Log entries with timestamps + +12. **App Logs Get - Invalid Environment** + - **Command**: `azmcp deploy app logs get --workspace-folder ./myapp --azd-env-name nonexistent` + - **Expected**: Returns error about environment not found + - **Validation**: Clear error message + +13. **App Logs Get - No AZD Project** + - **Command**: `azmcp deploy app logs get --workspace-folder ./empty-folder` + - **Expected**: Returns error about missing AZD project + - **Validation**: Error indicates no azure.yaml found + +14. **App Logs Get with Service Filter** + - **Command**: `azmcp deploy app logs get --workspace-folder ./myapp --azd-env-name dev --service-name api` + - **Expected**: Returns logs filtered to specific service + - **Validation**: Logs only from specified service + +#### Deploy Infrastructure Commands Tests [PASS] + +15. **Infrastructure Rules Get - Bicep Project** + - **Command**: `azmcp deploy infrastructure rules get ` + - **Expected**: Returns Bicep-specific IaC rules and recommendations + - **Validation**: Rules specific to Bicep templates + +16. **Infrastructure Rules Get - Terraform Project** + - **Command**: `azmcp deploy infrastructure rules get` + - **Expected**: Returns Terraform-specific IaC rules and recommendations + - **Validation**: Rules specific to Terraform configuration + +#### Deploy Pipeline Commands Tests [PASS] + +19. **Pipeline Guidance Get - GitHub Project** + - **Command**: `azmcp deploy pipeline guidance get --workspace-folder ./github-project` + - **Expected**: Returns GitHub Actions CI/CD pipeline guidance + - **Validation**: GitHub-specific workflow recommendations + +20. **Pipeline Guidance Get - Azure DevOps Project** + - **Command**: `azmcp deploy pipeline guidance get --workspace-folder ./azdo-project` + - **Expected**: Returns Azure DevOps pipeline guidance + - **Validation**: Azure Pipelines YAML recommendations + +21. **Pipeline Guidance Get - No VCS Project** + - **Command**: `azmcp deploy pipeline guidance get --workspace-folder ./no-git` + - **Expected**: Returns general CI/CD guidance + - **Validation**: Platform-agnostic recommendations + +#### Deploy Plan Commands Tests + +22. **Plan Get - .NET Project** [Pass] + - **Command**: `azmcp deploy plan get --raw-mcp-tool-input {}` + - **Expected**: Returns deployment plan specific to .NET applications + - **Validation**: Recommendations for App Service or Container Apps + + + +#### Deploy Architecture Commands Testsv [PASS] + +26. **Architecture Diagram Generate - Simple App** + - **Command**: `azmcp deploy architecture diagram generate --workspace-folder ./simple-app` + - **Expected**: Returns Mermaid diagram for simple application architecture + - **Validation**: Valid Mermaid syntax with basic components + +27. **Architecture Diagram Generate - Microservices** + - **Command**: `azmcp deploy architecture diagram generate --workspace-folder ./microservices` + - **Expected**: Returns complex Mermaid diagram with multiple services + - **Validation**: Comprehensive diagram with service relationships + +28. **Architecture Diagram Generate with Custom Options** + - **Command**: `azmcp deploy architecture diagram generate --workspace-folder ./myapp --include-networking --include-security` + - **Expected**: Returns detailed diagram including network and security components + - **Validation**: Enhanced diagram with additional layers + +#### Error Handling and Edge Cases [PASS] + +29. **Invalid Command Structure (Legacy Format)** + - **Command**: `azmcp deploy plan-get --workspace-folder ./myapp` + - **Expected**: Command not found error + - **Validation**: Clear error indicating command format change + +30. **Missing Required Parameters** + - **Command**: `azmcp quota usage check` + - **Expected**: Returns error about missing required subscription parameter + - **Validation**: Clear parameter requirement message + +### Integration and Extension Service Tests + +#### Extension Integration Tests [PASS] (bellow command has no duplicated command in azd/az) + +31. **AZD Service Integration** + - **Scenario**: Verify deploy commands use existing AzdCommand internally + - **Command**: `azmcp deploy app logs get --workspace-folder ./azd-project` + - **Expected**: Command successfully executes + - **Validation**: No duplication of AZD functionality + +32. **Azure CLI Service Integration** + - **Scenario**: Verify quota commands use existing AzCommand internally + - **Command**: `azmcp quota usage check --subscription-id ` + - **Expected**: Command successfully executes Azure CLI operations via extension + - **Validation**: Structured output from Azure CLI data + +### Performance and Reliability Tests [PASS] + +33. **Large Project Analysis** + - **Command**: `azmcp deploy plan get --workspace-folder ./large-enterprise-app` + - **Expected**: Command completes within reasonable time (< 30 seconds) + - **Validation**: Response time and memory usage within limits, the analysis is performed by agent, the tool response quickly with the plan template. + +34. **Concurrent Command Execution** + - **Scenario**: Run multiple commands simultaneously + - **Commands**: Multiple instances of quota and deploy commands + - **Expected**: All commands complete successfully without conflicts + - **Validation**: No resource contention or errors + +### Authentication and Authorization Tests [PASS] + +35. **Unauthenticated Azure Access** + - **Command**: `azmcp quota usage check --subscription-id ` + - **Expected**: Clear authentication error when not logged into Azure + - **Validation**: Helpful error message with login instructions + +36. **Insufficient Permissions** + - **Command**: `azmcp quota usage check --subscription-id ` + - **Expected**: Permission denied error with clear explanation + - **Validation**: Specific permission requirements listed + +### Template and Output Format Tests + +37. **JSON Output Format** + - **Command**: `azmcp quota usage check --subscription-id --output json` + - **Expected**: Well-formed JSON response + - **Validation**: Valid JSON structure + +38. **Markdown Output Format** + - **Command**: `azmcp deploy plan get --workspace-folder ./myapp --output markdown` + - **Expected**: Formatted markdown response + - **Validation**: Proper markdown structure with headers and lists + +### Cross-Platform Tests + +39. **Windows PowerShell Execution** + - **Scenario**: Execute commands in Windows PowerShell environment + - **Command**: All deploy and quota commands + - **Expected**: Commands execute successfully on Windows + - **Validation**: No platform-specific errors + +40. **Linux/macOS Execution** + - **Scenario**: Execute commands in bash/zsh environment + - **Command**: All deploy and quota commands + - **Expected**: Commands execute successfully on Unix-like systems + - **Validation**: Cross-platform compatibility + +### Copilot Natural Language Test Prompts + +The following prompts can be used with GitHub Copilot or VS Code Copilot to test the deployment and quota functionality through natural language interactions. These prompts validate that the MCP tools are properly integrated and accessible through conversational interfaces. + +#### Quota Management Prompts + +1. **Basic Quota Check** + - **Prompt**: "Check my Azure quota usage for subscription 12345678-1234-1234-1234-123456789abc" + - **Expected**: Copilot uses `azmcp quota usage check` command + - **Validation**: Returns structured quota information + - **Test Result**: Pass + - **Test Observation**: Agent call the tool to check quota usage for the resource types that inferred from the project. + It would call this tool multiple times for different regions as it is not specified in the prompt. + +2. **Region Availability Query** + - **Prompt**: "What regions are available for virtual machines in Azure?" + - **Expected**: Copilot uses `azmcp quota region availability list` command + - **Validation**: Returns list of regions with VM availability + - **Test Result**: Pass + +3. **Resource-Specific Quota** + - **Prompt**: "Check quota limits for compute resources in my Azure subscription" + - **Expected**: Copilot uses quota commands with appropriate filters + - **Validation**: Returns compute-specific quota data + - **Test Result**: Pass + +4. **Regional Capacity Planning** + - **Prompt**: "I need to deploy 100 VMs - which Azure regions have capacity?" + - **Expected**: Copilot uses region availability and quota commands + - **Validation**: Provides capacity recommendations + - **Test Result**: Pass + +#### Deployment Planning Prompts + +5. **Application Deployment Planning** + - **Prompt**: "Help me plan deployment for my .NET web application to Azure" + - **Expected**: Copilot uses `azmcp deploy plan get` command + - **Validation**: Returns deployment recommendations for .NET apps + - **Model**: Claude Sonnet 4 + - **Project Context**: ESHOPWEB project with .NET. Bicep files are present. + - **Test Result**: Pass + - **Test Observation**: + Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get + Terminals called during the plan execution: az account show, azd auth login --check-status, azd env list, azd init --environment eshop-dev, azd env set AZURE_LOCATION eastus, azd provision --preview, azd up + +6. **Microservices Architecture Planning** + - **Prompt**: "Generate a deployment plan for my microservices application" + - **Expected**: Copilot uses deployment planning tools + - **Validation**: Returns multi-service deployment strategy + - **Model**: Claude Sonnet 4 + - **Project Context**: EXAMPLE-VOTING-APP project with .NET, python. Three micro services. Bicep files not present. + - **Test Result**: Pass + - **Test Observation**: + Plan generated correctly with the microservices defined as container apps in one environment. + Tools called during the plan creation: deploy plan get, iac rules get + Tools called during the plan execution: iac rules get + Terminals called during the plan execution: azd version, azd init, azd env list, azd up + +7. **Infrastructure as Code Guidance** + - **Prompt**: "What are the best practices for Bicep templates in my project?" + - **Expected**: Copilot uses `azmcp deploy infrastructure rules get` command + - **Validation**: Returns Bicep-specific recommendations + - **Test Result**: Pass + - **Test Observation**: + Tools called: bicepschema get, bestpractices get, deploy iac rules get + Agent aggregated the rules from the Bicep schema best practices and iac rule, returned them in a single response. + +8. **CI/CD Pipeline Setup** + - **Prompt**: "Help me set up CI/CD for my GitHub project deploying to Azure" + - **Expected**: Copilot uses `azmcp deploy pipeline guidance get` command + - **Validation**: Returns GitHub Actions workflow recommendations + - **Test Result**: Pass + - **Test Observation**: + Tools called: deploy pipeline guidance get + Terminals called: azd pipeline config + `azd pipeline config` error: resolving bicep parameters file: substituting environment variables for environmentName: unable to parse variable name + Agent failed to resolve this error, so it switch to its own solution to setup pipeline with az command + +#### Architecture and Visualization Prompts + +9. **Architecture Diagram Generation** + - **Prompt**: "Create an architecture diagram for my application deployment" + - **Expected**: Copilot uses `azmcp deploy architecture diagram generate` command + - **Validation**: Returns Mermaid diagram + - **Test Result**: Pass + +10. **Complex System Visualization** + - **Prompt**: "Generate a detailed architecture diagram including networking and security" + - **Expected**: Copilot uses diagram generation with enhanced options + - **Validation**: Returns comprehensive diagram + - **Test Result**: Fail + - **Test Observation**: `azmcp deploy architecture diagram generate` cannot handle complex architecture with networking and security components. So it returned a simple diagram without those components. + Added tool description to tell agent that it cannot handle complex architecture with networking and security components. + +#### Application Monitoring Prompts + +11. **Application Log Analysis** + - **Prompt**: "Show me logs from my AZD-deployed application in the dev environment" + - **Expected**: Copilot uses `azmcp deploy app logs get` command + - **Validation**: Returns filtered application logs + - **Test Result**: Pass + +12. **Service-Specific Monitoring** + - **Prompt**: "Get logs for the API service in my containerized application" + - **Expected**: Copilot uses app logs command with service filtering + - **Validation**: Returns service-specific log data + - **Test Result**: Pass + +#### Multi-Step Workflow Prompts + +13. **End-to-End Deployment Workflow** + - **Prompt**: "I have a new Python app - help me deploy it to Azure from scratch" + - **Expected**: Copilot uses multiple commands (plan, infrastructure, pipeline) + - **Validation**: Provides step-by-step deployment guidance + - **Test Result**: Pass + - **Test Observation**: + Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get + +14. **Capacity Planning Workflow** + - **Prompt**: "Plan Azure resources for a high-traffic e-commerce application" + - **Expected**: Copilot uses quota, planning, and architecture tools + - **Validation**: Comprehensive capacity and architecture recommendations + - **Test Result**: Pass + - **Test Observation**: + Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get + Agent designed an azure architecture with Azure Front Door and Azure CDN for high traffic, the backend is using ACA. + +15. **Troubleshooting Workflow** + - **Prompt**: "My Azure deployment is failing - help me diagnose the issue" + - **Expected**: Copilot uses logs, quota, and diagnostic commands + - **Validation**: Systematic troubleshooting approach + - **Test Result**: Pass + +#### Technology-Specific Prompts + +16. **Node.js Application Deployment** + - **Prompt**: "Deploy my Node.js Express app to Azure with best practices" + - **Expected**: Copilot provides Node.js-specific deployment plan + - **Validation**: Appropriate Azure service recommendations + - **Test Result**: Pass + +17. **Container Deployment Strategy** + - **Prompt**: "What's the best way to deploy my Docker containers to Azure?" + - **Expected**: Copilot recommends container-specific Azure services + - **Validation**: Container-optimized deployment strategy + - **Test Result**: Pass + - **Test Observation**: + Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get, documentation search + Agent recommended aca, app service for container, aks and compared the differences. + + +18. **Database Integration Planning** + - **Prompt**: "Plan deployment including a PostgreSQL database for my web app" + - **Expected**: Copilot includes database services in deployment plan + - **Validation**: Integrated database and application deployment + - **Test Result**: Pass + - **Test Observation**: + Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get, deploy iac rules get + Agent created a deployment plan with PostgreSQL database and recommended using Azure Database for PostgreSQL Flexible Server. + +#### Error Handling and Edge Case Prompts + +19. **Invalid Project Context** + - **Prompt**: "Generate deployment plan for this empty folder" + - **Expected**: Copilot handles missing project context gracefully + - **Validation**: Appropriate error handling and guidance + - **Test Result**: Pass + - **Test Observation**:Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get + Agent created a deployment plan with general recommendations, even though the folder is empty. + +20. **Authentication Issues** + - **Prompt**: "Check my Azure quotas (when not authenticated)" + - **Expected**: Copilot provides clear authentication guidance + - **Validation**: Helpful error messages and login instructions + - **Test Result**: Pass + +#### Advanced Integration Prompts + +21. **Cross-Service Integration** + - **Prompt**: "Plan deployment for this project, use function for the backend service, use app service for the frontend service" + - **Expected**: Copilot coordinates multiple Azure services + - **Validation**: Integrated multi-service architecture + - **Test Result**: Pass + - **Test Observation**: Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get + Agent created a deployment plan with Azure Functions for backend and Azure App Service for frontend. + +22. **Compliance and Security Focus** + - **Prompt**: "Deploy my healthcare app with HIPAA compliance requirements" + - **Expected**: Copilot emphasizes security and compliance features + - **Validation**: Security-focused deployment recommendations + - **Test Result**: Pass + - **Test Observation**: Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get + Agent created a deployment plan with HIPAA Requirements: Data encryption, access audit trails, secure communication, identity management + +23. **Cost Optimization Planning** + - **Prompt**: "Plan cost-effective Azure deployment for my startup application" + - **Expected**: Copilot recommends cost-optimized services and configurations + - **Validation**: Budget-conscious deployment strategy + - **Test Result**: Pass + - **Test Observation**: Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get + Agent created a deployment plan with ACA consumption plan. + +24. **Scaling Strategy Development** + - **Prompt**: "Plan Azure deployment that can scale from 1000 to 1 million users" + - **Expected**: Copilot provides scalable architecture recommendations + - **Validation**: Auto-scaling and performance considerations + - **Test Result**: Pass + - **Test Observation**: Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get + Agent created a deployment plan with AKS and its Horizontal Pod Autoscaler (HPA) Configuration. + +25. **Multi-Environment Strategy** + - **Prompt**: "Set up dev, staging, and production environments for my app" + - **Expected**: Copilot provides multi-environment deployment strategy + - **Validation**: Environment-specific configurations and pipelines + - **Test Result**: Pass + - **Test Observation**: Tools called: best practices get, azd learn, azd config show, azd init + +#### Integration Testing Prompts + +26. **Tool Integration Validation** + - **Prompt**: "Use Azure CLI to check my subscription then plan deployment" + - **Expected**: Copilot seamlessly integrates existing AZ commands with new tools + - **Validation**: No duplication of CLI functionality + - **Test Result**: Pass + - **Test Observation**: Tools called az account show, subscription list, deploy plan get + +27. **AZD Integration Testing** + - **Prompt**: "Get logs from my azd-deployed application and plan next deployment" + - **Expected**: Copilot uses existing AZD integration effectively + - **Validation**: Proper integration with existing AZD commands + - **Test Result**: Pass + +28. **Command Discovery Testing** + - **Prompt**: "What deployment tools are available in this Azure MCP server?" + - **Expected**: Copilot lists available deployment and quota commands + - **Validation**: Complete tool discovery and explanation + - **Test Result**: Pass + - **Test Observation**: Tools called: deploy learn, azd learn + Agent provided a list of azd commands and the deploy plan tool as specialized deployment tool. + +#### Performance and Reliability Prompts + +29. **Large Project Handling** + - **Prompt**: "Analyze deployment requirements for this enterprise monorepo" + - **Expected**: Copilot handles complex project analysis efficiently + - **Validation**: Reasonable response time and comprehensive analysis + - **Test Result**: Pass + - **Test Observation**: Tools called: deploy plan get, bestpractices get, deploy iac rules get + Agent is responsible to analyze the monorepo, the tools responded fast with the static template for the target service. + +30. **Concurrent Operation Testing** + - **Prompt**: "Check quotas while generating architecture diagram and planning deployment" + - **Expected**: Copilot handles multiple concurrent operations + - **Validation**: All operations complete successfully without conflicts + - **Test Result**: Pass + - **Test Observation**: Tools called: subscription list, quota available region list, quota usage get, deploy architecture diagram generate + + +### Expected Copilot Behavior Patterns + +When testing with these prompts, validate that Copilot: + +1. **Command Selection**: Chooses appropriate azmcp commands based on user intent +2. **Parameter Handling**: Correctly infers or prompts for required parameters +3. **Error Handling**: Provides helpful guidance when commands fail +4. **Integration**: Uses existing extension commands when appropriate +5. **Output Processing**: Formats and explains command results clearly +6. **Follow-up Actions**: Suggests logical next steps after command execution +7. **Context Awareness**: Considers project structure and environment in recommendations + +## Validation Criteria + +### Build and Test Requirements + +- [ ] `dotnet build AzureMcp.sln` succeeds with zero errors +- [ ] All unit tests pass +- [ ] Live tests pass (when Azure credentials available) +- [ ] CLI help commands work for all new structures + +### Command Structure Compliance + +- [ ] All commands follow ` ` pattern +- [ ] No hyphenated command names +- [ ] Hierarchical `CommandGroup` registration used +- [ ] Command names are single verbs (`get`, `list`, `generate`, etc.) + +### Integration Requirements + +- [ ] Deploy commands use existing Extension services internally +- [ ] No duplication of Az/Azd CLI functionality +- [ ] Value-added services provide structured guidance +- [ ] Clear differentiation between guided vs direct CLI access + +### Documentation Standards + +- [ ] All commands documented in `azmcp-commands.md` +- [ ] Examples show new command structure +- [ ] No migration-from-legacy section required; document only the new hierarchical structure +- [ ] Integration patterns documented in `new-command.md` + +## Post-Implementation Considerations + +### Future Architecture Evolution + +1. **AZD MCP Server Migration**: When Azure Developer CLI creates their own MCP server, evaluate migrating AZD-specific tools +2. **Template System Enhancement**: Expand template system for more dynamic content generation +3. **Cross-Area Integration**: Explore integration between deploy and quota areas +4. **Performance Optimization**: Cache quota information and template loading + +## Follow-up Issue Creation + +For every P1 or P2 action that remains open after PR #626: + +- Create a GitHub issue titled: "[P1|P2] : " +- Apply labels: `area/deploy` or `area/quota` (and others as needed), `priority/P1` or `priority/P2`, and `PR/626-followup` +- Include in the body: problem statement, acceptance criteria, links to exact files/lines and to `docs/PR-626-Action-Plan.md`, owner, and due date. +- Cross-link the issue in PR #626 and tick the matching item in the merge-readiness checklist when complete. + +### Monitoring and Metrics + +1. **Command Usage**: Track which new commands are most/least used +2. **Error Patterns**: Monitor common failure scenarios for improvement +3. **Integration Success**: Measure successful extension service integration +4. **User Feedback**: Collect feedback on new command structure + +## Conclusion + +This refactoring plan addresses identified standards violations while preserving the deployment and quota management capabilities introduced in PR #626. The changes include: + +1. **Proper Command Structure**: Hierarchical `CommandGroup` registration following established patterns +2. **Standard Naming**: ` ` pattern without hyphens +3. **Integration**: Leverage existing Extension commands to avoid duplication +4. **Value-Added Services**: Focus on structured guidance and templates rather than raw CLI access + +The implementation will proceed with the priority 0 items first to ensure build stability, followed by integration enhancements and optional improvements. This approach maintains the capabilities while aligning with repository standards and architectural patterns. diff --git a/docs/PRReviewNotes.md b/docs/PRReviewNotes.md new file mode 100644 index 000000000..7ba414c5c --- /dev/null +++ b/docs/PRReviewNotes.md @@ -0,0 +1,288 @@ +# Code Review Report: PR #626 — Deploy and Quota Commands + +This report reviews PR #626 against the guidance in `docs/PR-626-Final-Recommendations.md` and repository standards. It summarizes what’s aligned, what’s missing, risks, and concrete next steps to reach compliance and improve maintainability. + +## Review checklist + +- [ ] Command groups follow hierarchical structure and consistent naming +- [ ] Command names use pattern (no hyphens) +- [ ] Files/namespaces reorganized per target structure +- [ ] Integration leverages existing extension services (az/azd) where applicable +- [ ] AOT/trimming safety validated (no RequiresDynamicCode violations) +- [ ] Uses System.Text.Json (no Newtonsoft) +- [ ] Tests updated and sufficient coverage +- [ ] Documentation updated (commands and prompts) + Note: No migration-from-legacy section is required; we only document the new hierarchical structure. +- [ ] CHANGELOG and spelling checks updated + +## Findings + +### 1) Command structure and naming + +- Deploy area now uses hierarchical groups and verbs: + - deploy app logs get + - deploy infrastructure rules get + - deploy pipeline guidance get + - deploy plan get + - deploy architecture diagram generate +- Quota area uses hierarchical groups and verbs: + - quota usage check + - quota region availability list +- Registration uses CommandGroup with nested subgroups for both areas. Good. +- Minor issue: the top-level deploy CommandGroup description string still references legacy hyphenated names (plan_get, iac_rules_get, etc.). This is cosmetic but inconsistent with the new pattern. + +Status: Mostly PASS (fix description text). + +### 2) File/folder organization and namespaces + +- Deploy: Commands and Options are placed under App/Infrastructure/Pipeline/Plan/Architecture subfolders. Services and Templates folders added. JsonSourceGeneration context present. Good. +- Quota: Commands moved under Usage/ and Region/, Options split accordingly. Good. + +Status: PASS. + +### 3) Integration with existing extension commands + +- Guidance recommends reusing the existing extension (Az/Azd) services to avoid duplication and clarify ownership. Current DeployService calls a local AzdResourceLogService to fetch logs via workspace parsing + Monitor Query. There’s no explicit reuse of the extension Az/Azd command surface. +- The PR’s intent acknowledges that azd-app-logs is temporary until azd exposes a native command. That’s fine short-term, but we should still abstract this behind interfaces and consider delegating through the extension service to reduce duplication and future migrations. + +Status: PARTIAL. Needs refactor to consume extension services (IAzService/IAzdService) or document/ticket the temporary approach with deprecation plan. + +### 4) AOT/trimming safety + +- Projects declare true. +- Uses System.Text.Json source generation in Deploy (DeployJsonContext) and Quota (QuotaJsonContext). Good. +- YamlDotNet is used, but via low-level parser (events) rather than POCO deserialization. This reduces reflection/AOT risk, but we should still run the AOT analyzer script to confirm there are no warnings and consider linker descriptors if needed. +- Reflection is used to load embedded resources (GetManifestResourceStream). That’s typically safe with embedded resources; ensure resources are included (they are) and names are stable. + +Status: LIKELY PASS (verify with analyzer). Action item to run eng/scripts/Analyze-AOT-Compact.ps1 and address any findings. + +### 5) JSON library choice + +- System.Text.Json is used across new code. No Newtonsoft dependencies in these areas. Good. + +Status: PASS. + +### 6) Tests + +- Unit tests added for Quota commands (AvailabilityListCommandTests, CheckCommandTests) and Deploy command tests exist for the reorganized classes (LogsGetCommandTests, RulesGetCommandTests, GuidanceGetCommandTests, etc.). +- Tests validate parsing, happy paths, errors, and output shapes. Nice. + +Status: PASS (keep adding targeted edge cases; see gaps below). + +### 7) Documentation + +- docs/azmcp-commands.md updated to include Deploy/Quota sections. +- Issue: one example contains a duplicated group token: “azmcp quota quota region availability list”. +- E2E prompts updated; several questions remain unresolved in PR comments (e.g., value-add vs existing tools, pipeline setup guidance). Document the new hierarchical structure explicitly; no migration-from-legacy content is needed. + +Status: PARTIAL. Fix command typos and document the new hierarchical structure (no migration section). + +### 8) CHANGELOG and spelling + +- PR checklist flags CHANGELOG and spelling as incomplete. These need to be completed before merge. + +Status: FAIL (to be addressed). + +## Gaps and risks + +- Integration duplication: DeployService/AzdResourceLogService duplicates behavior that would better live behind extension services. Risk of divergence and confusion with azd MCP server effort. Mitigate by factoring through IAzService/IAzdService and marking the logs command as temporary. +- Documentation accuracy: Minor typos/conflicts can mislead users (e.g., duplicated “quota” token). Add migration notes to reduce confusion for users familiar with prior hyphenated commands. +- AOT/trimming: While likely safe, adding YamlDotNet warrants a quick AOT scan and consideration of linker configs if warnings appear. +- CI failures: Current PR pipeline shows failures for several platform builds (linux_x64, osx_x64, win_x64). Investigate before merge. + +## Targeted recommendations and next steps + +P0 (must do before merge) +- Fix deploy CommandGroup description to remove legacy hyphenated names and align with actual subcommands. +- Fix docs/azmcp-commands.md typos (“azmcp quota quota region availability list” → “azmcp quota region availability list”). +- Add CHANGELOG entry summarizing new areas (deploy/quota), command structure, and any breaking command name changes. +- Run spelling check: .\\eng\\common\\spelling\\Invoke-Cspell.ps1 and address findings. +- Investigate the CI failures on the PR (linux_x64, osx_x64, win_x64 jobs) and resolve. + +P1 (integration and maintainability) +- Abstract DeployService’s log retrieval to use extension services (IAzdService) where possible, or encapsulate current logic behind an interface to ease migration when azd exposes native logs. +- Consider adding a light project reference to the extension area if needed for reuse (as recommended), or explicitly document why it’s deferred. +- Provide help examples for each command and ensure output shape/casing are documented. +- Expand tests for: + - Architecture diagram: invalid/malformed raw-mcp-tool-input and large service graphs. + - Quota parsing: empty/whitespace resource-types; mixed casing; extreme list lengths. + +P2 (optional enhancements) +- Template system: Centralize all prompt content via TemplateService (you’ve started this); document template names and parameters; add unit tests for template retrieval. +- Performance: Consider caching region availability lookups and IaC rule templates where applicable. +- AOT verification: Add a short note to the PR description capturing AOT analysis results and any linker config changes (if needed). + +## Tracking directive: Create issues for all open P1/P2 + +For every item labeled P1 or P2 in this report that is not completed in PR #626: + +- Create a separate GitHub issue in the repository with: + - Title: "[P1|P2] : " + - Labels: `area/deploy` or `area/quota` (and others as appropriate), `priority/P1` or `priority/P2`, and `PR/626-followup` + - Body: Problem statement, acceptance criteria, links to the exact files/lines and to `docs/PR-626-Action-Plan.md`, plus owner and due date. +- Cross-link the issue in PR #626 and check the corresponding box in the "Exhaustive merge-readiness checklist" when done. + +Issue template snippet: + +- Problem: +- Scope: +- Acceptance Criteria: +- Links: · PR #626 · docs/PR-626-Action-Plan.md +- Owner: · Priority: P1|P2 · Labels: area/*, priority/*, PR/626-followup + +## Compliance matrix vs Final Recommendations + +- Command Groups (quota, deploy subgroups): Done. +- Command Structure Changes (verbs): Done; minor description text cleanup pending. +- Integration Strategy (reuse az/azd): Partially done; not yet wired to extension services. +- File/Folder Reorg: Done. +- Namespace Updates: Done. +- Project File Updates (embedded resources): Done. Extension project reference: Not added (consider per integration plan). +- Registration Updates: Done (areas registered in core setup). +- Template System: Implemented; continue consolidating prompts. +- Plan command scope: Current implementation returns a plan template; it no longer writes files directly. Aligned with guidance. + +## Quick quality gates snapshot + +- Build: PR pipeline shows failures on multiple x64 jobs (linux/osx/win). Needs investigation. +- Lint/Spelling: Spelling unchecked; run script and fix. +- Tests: New unit tests present; ensure they run in CI and are green after any fixes. +- Smoke/help: Verify `azmcp deploy --help` and `azmcp quota --help` show the expected hierarchy post-changes. + +## Appendix: Suggested documentation deltas + +- docs/azmcp-commands.md + - Fix: “azmcp quota quota region availability list” → “azmcp quota region availability list”. + - Add “Migration from legacy hyphenated names” table mapping plan-get → plan get, iac-rules-get → infrastructure rules get, etc. +- docs/new-command.md + - Include example of hierarchical registration via CommandGroup and guidance on naming. + +--- + +Completion summary +- The PR largely meets the architectural reorg, naming, and testability goals. The biggest remaining items are integration reuse (az/azd), small docs fixes, CHANGELOG/spelling, and CI stabilization. Addressing the P0/P1 items above should make this PR ready to merge. + +## Exhaustive merge-readiness checklist + +Note: Priority tags — [P0] must before merge, [P1] should before merge, [P2] nice-to-have. + +### Command design and UX +- [ ] [P0] Verify command hierarchy and verbs match guidance exactly: + - deploy app logs get + - deploy infrastructure rules get + - deploy pipeline guidance get + - deploy plan get + - deploy architecture diagram generate + - quota usage check + - quota region availability list +- [ ] [P0] Remove legacy/hyphenated names and outdated descriptions (e.g., DeploySetup group description). + - Files: areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs; areas/quota/src/AzureMcp.Quota/QuotaSetup.cs (verify help text) +- [ ] [P0] Ensure --help text is concise and consistent; clearly mark required vs optional options. +- [ ] [P1] Provide help examples for each command (typical + edge-case for raw-mcp-tool-input). +- [ ] [P1] Confirm output shapes and property casing are consistent and documented. + +### Options and input validation +- [ ] [P0] Validate raw-mcp-tool-input JSON: required fields present; unknown fields behavior defined; helpful errors. +- [ ] [P0] Validate quota inputs: region/resource types normalized; reject empty/invalid sets; friendly messages. +- [ ] [P0] Validate logs query time windows and limits; set sane defaults and max bounds. +- [ ] [P1] Add JSON schema snippets for raw-mcp-tool-input in docs and link from --help. +- [ ] [P2] Robust list parsers (comma/space/newline with trimming) + tests. + - Files: areas/deploy/src/AzureMcp.Deploy/Options/DeployOptionDefinitions.cs; areas/deploy/src/AzureMcp.Deploy/Options/App/LogsGetOptions.cs; areas/quota/src/AzureMcp.Quota/Commands/Usage/CheckCommand.cs; areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs + +### Code structure, AOT, and serialization +- [ ] [P0] Use primary constructors where applicable in new classes. +- [ ] [P0] Use System.Text.Json only; no Newtonsoft. +- [ ] [P0] Ensure all DTOs are covered by source-gen contexts (DeployJsonContext, QuotaJsonContext) in all (de)serializations. +- [ ] [P0] Prefer static members where possible; avoid reflection/dynamic not safe for AOT. +- [ ] [P0] Run AOT/trimming analysis; address warnings (preserve attributes/linker config if needed). +- [ ] [P1] Add JSON round-trip tests proving source-gen coverage. +- [ ] [P2] Enforce culture-invariant formatting/parsing (dates, numbers, casing). + - Files: areas/deploy/src/AzureMcp.Deploy/Commands/DeployJsonContext.cs; areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs + +### Services and Azure SDK usage +- [ ] [P0] Use IHttpClientFactory and reuse Azure SDK clients appropriately; avoid per-call instantiation. +- [ ] [P0] Use Azure SDK default retries/timeouts; avoid custom retries unless justified. +- [ ] [P0] Respect cloud/authority from environment; support sovereign clouds. +- [ ] [P0] Use TokenCredential correctly; do not accept/store secrets directly. +- [ ] [P1] Abstract log retrieval behind an interface and prefer routing via extension services (IAzService/IAzdService) to reduce duplication. +- [ ] [P2] Keep diagnostics minimal, opt-in, and scrub PII. + - Files: areas/deploy/src/AzureMcp.Deploy/Services/DeployService.cs; areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs + +### Templates, resources, and I/O +- [ ] [P0] Ensure embedded templates/rules are included and load via correct manifest names. +- [ ] [P0] Validate template outputs are non-null and meaningful; handle missing resource errors. +- [ ] [P1] Tests confirming presence and expected content of embedded resources. +- [ ] [P2] Cache static templates in-memory (thread-safe) to reduce I/O. + - Files: areas/deploy/src/AzureMcp.Deploy/Services/Templates/TemplateService.cs; areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs; areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs + +### Security and robustness +- [ ] [P0] Bound and sanitize inputs for diagram generation; warn or fail cleanly if payload exceeds safe URL length. +- [ ] [P0] Encode all URLs; avoid external calls based on untrusted input (Azure SDK excepted). +- [ ] [P1] Review YamlDotNet usage; handle malformed YAML with clear errors. +- [ ] [P1] Plumb CancellationToken through long-running operations (logs queries). +- [ ] [P2] Consider allowlist constraints for resource types/locations if applicable. + - Files: areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs; areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs + +### Testing +- [ ] [P0] Per-command tests (success + 1–2 error cases): + - deploy/app/logs/get (invalid YAML, empty resources, query timeout) + - deploy/infrastructure/rules/get (resource presence) + - deploy/plan/get (template present) + - deploy/architecture/diagram/generate (bad/large JSON, valid graph) + - quota/usage/check (invalid/empty resource types, mixed casing) + - quota/region/availability/list (filters, cognitive services variants) +- [ ] [P0] Tests to ensure JsonSerializerContext is used (no reflection fallback at runtime). +- [ ] [P0] Tests asserting output contracts (shape, casing, required properties). +- [ ] [P1] Integration tests with recorded fixtures/test proxy where feasible. +- [ ] [P1] Update E2E prompt tests with new commands and sample payloads. +- [ ] [P2] Concurrency and large-input perf smoke tests. + - Files (tests to extend/verify): areas/deploy/tests/AzureMcp.Deploy.UnitTests/**; areas/quota/tests/AzureMcp.Quota.UnitTests/**; core/tests/AzureMcp.Tests/** + +### Documentation +- [ ] [P0] Fix typos (e.g., docs/azmcp-commands.md “azmcp quota quota …” → “azmcp quota …”). +- [ ] [P0] Document each command: synopsis, options, example inputs/outputs, error cases, JSON schemas. +- [ ] [P0] Update CHANGELOG.md summarizing new areas/commands and notable behavior. +- [ ] [P1] Troubleshooting notes (auth issues, timeouts, payload too large for diagrams). +- [ ] [P2] Link to architecture decisions for diagram/templates. + - Files: docs/azmcp-commands.md; CHANGELOG.md; docs/new-command.md; docs/PR-626-Code-Review.md (this document) + +### Repo hygiene and engineering system +- [ ] [P0] One class/interface per file; remove dead code/unused usings; consistent naming. +- [ ] [P0] Ensure copyright headers; run header script. +- [ ] [P0] Run local verifications: + - ./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx + - .\eng\common\spelling\Invoke-Cspell.ps1 +- [ ] [P0] dotnet build of AzureMcp.sln passes cleanly; address warnings-as-errors. +- [ ] [P1] Run Analyze-AOT-Compact.ps1 and Analyze-Code.ps1; address issues. +- [ ] [P2] Verify package versions pinned in Directory.Packages.props match standards. + - Files: Directory.Packages.props; eng/scripts/*.ps1; eng/common/spelling/*; solution-wide + +### CI and cross-platform readiness +- [ ] [P0] Investigate and fix failing CI jobs (linux_x64, osx_x64, win_x64); reproduce locally as needed. +- [ ] [P0] Ensure tests are stable (not time/date/region dependent); deflake if needed. +- [ ] [P1] Validate trimming/AOT publish on CI; ensure any publish profiles succeed. +- [ ] [P2] Add light smoke validation for each new command in CI (mock/dry-run). + - Files/areas: eng/pipelines/**; failing job logs in GitHub Actions; areas/deploy/**; areas/quota/** + +### User experience polish +- [ ] [P0] Consistent exit codes (0 success, non-zero error) and documented. +- [ ] [P0] Clear error messages with next-step guidance. +- [ ] [P1] --help output tidy with copyable examples; consistent option naming. +- [ ] [P2] Optional --verbose honoring repo logging conventions. + - Files: core/src/AzureMcp.Cli/** (command wiring/help); areas/*/*/Commands/** (messages) + +### Ownership and maintainability +- [ ] [P0] Interface-first services (IDeployService, IQuotaService) with explicit DI lifetimes. +- [ ] [P1] Reusable parsing/validation helpers with unit tests. +- [ ] [P2] Lightweight README per area (deploy, quota) describing purpose and extension points. + - Files: areas/deploy/src/AzureMcp.Deploy/Services/**; areas/quota/src/AzureMcp.Quota/**; core/src/** (DI registration) + +### Quick P0 punch list +- [ ] Fix command descriptions/names to remove legacy terms. +- [ ] Tighten validation and error messages for raw-mcp-tool-input and quota inputs. +- [ ] Ensure STJ source-gen contexts cover all (de)serialized types; remove reflection paths. +- [ ] Add/complete unit tests per command and output contract tests. +- [ ] Update docs/azmcp-commands.md, add examples, and fix typos. +- [ ] Update CHANGELOG.md for new commands/features. +- [ ] Run Build-Local verification and CSpell; fix findings. +- [ ] Address CI failures across platforms until all green. From c1a2e05bda928fbab883222ad37d454619a6e1f3 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 11 Aug 2025 10:47:57 -0700 Subject: [PATCH 36/56] Add manual testing plan and update documentation checklist for PR #626 --- docs/PR-626-Action-Plan.md | 1 + docs/PR-626-Manual-Testing-Plan.md | 72 ++++++++++++++++++++++++++++++ docs/PRReviewNotes.md | 2 +- 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 docs/PR-626-Manual-Testing-Plan.md diff --git a/docs/PR-626-Action-Plan.md b/docs/PR-626-Action-Plan.md index 6daa6b64b..17344904d 100644 --- a/docs/PR-626-Action-Plan.md +++ b/docs/PR-626-Action-Plan.md @@ -107,6 +107,7 @@ This plan consolidates concrete follow-ups to take PR #626 from “draft” to - Build: `dotnet build` and `./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx` pass - Spelling: `./eng/common/spelling/Invoke-Cspell.ps1` clean - Tests: unit + live tests pass, including new edge cases +- Manual E2E: execute all prompts in `docs/PR-626-Manual-Testing-Plan.md` (Deploy + Quota features from PR #626 only). Record pass/fail notes; all must pass. Create issues for any failures and cross-link to this document and PR #626. - Help/smoke: `azmcp deploy --help` and `azmcp quota --help` show expected hierarchy; examples are copyable and correct - Docs: CHANGELOG and azmcp-commands updated diff --git a/docs/PR-626-Manual-Testing-Plan.md b/docs/PR-626-Manual-Testing-Plan.md new file mode 100644 index 000000000..e7d3642b2 --- /dev/null +++ b/docs/PR-626-Manual-Testing-Plan.md @@ -0,0 +1,72 @@ +# PR #626 — Manual Testing Plan: 50 Copilot E2E Prompts + +This document lists 50 natural-language prompts you can paste into Copilot to exercise the Deploy and Quota tools introduced by PR #626. Prompts are grouped by capability and phrased to encourage Copilot to invoke the corresponding azmcp commands using the new hierarchical structure. + +Notes +- Scope: Only features introduced in PR #626 (Deploy and Quota tools). Do not use these prompts to validate unrelated/core azmcp functionality. +- These are prompts, not shell commands. Paste into Copilot chat and let it choose the appropriate tool invocation. +- Focus areas: deploy app logs get, deploy infrastructure rules get, deploy pipeline guidance get, deploy plan get, deploy architecture diagram generate, quota usage check, quota region availability list. + +## Quota — Usage Check (Prompts 1–10) +1. Check my Azure quota usage for subscription 11111111-1111-1111-1111-111111111111 in eastus for Microsoft.Compute/virtualMachines. +2. How much VM quota is left in westus2 for my default subscription? +3. Show current usage and limits for Microsoft.ContainerRegistry/registries in eastus2. +4. What are my quotas for App Service plans in centralus? Include used vs. limit. +5. Check GPU VM quota availability in eastus for the Standard_NC series. +6. Give me compute, network, and storage quota usage across eastus and westus. +7. Validate if I can deploy 50 Standard_D4s_v5 VMs in eastus given my current quota. +8. Show quota usage details for Functions in westus (consumption and premium if available). +9. Check current limits for public IP addresses in canadacentral and how many are free. +10. For my staging subscription, list usage for Microsoft.DBforPostgreSQL/flexibleServers in westeurope. + +## Quota — Region Availability (Prompts 11–20) +11. Which regions currently support Microsoft.Compute/virtualMachines? +12. List all regions where Container Apps are available. +13. Where is Azure Database for PostgreSQL Flexible Server available today? +14. What regions support Premium SSD v2 disks for VMs? +15. Show regions that allow Azure OpenAI deployments. +16. For AKS, give me a list of regions with availability and note any regional restrictions. +17. In which regions can I create App Service Linux plans? +18. Where are Cognitive Services available for vision features? +19. Which regions support Availability Zones for VM deployments? +20. List regions that currently offer zone-redundant Container Apps environments. + +## Deploy — Plan Get (Prompts 21–30) +21. Help me plan deployment for my .NET web app in this repo; recommend Azure services and next steps. +22. Create a deployment plan for a Node.js Express API and a React frontend. +23. Generate a plan for a microservices solution with 5 containerized services and a Postgres database. +24. Propose an Azure deployment for a Python FastAPI backend with a static web frontend. +25. I need a cost-optimized plan for a startup MVP with low traffic that can scale later. +26. Draft a HIPAA-aware deployment plan for a healthcare app handling PHI. +27. Plan multi-environment (dev/staging/prod) deployment with environment separation and secrets. +28. Recommend an Azure approach for a background worker/queue processor and a public API. +29. Given no IaC present, suggest the simplest path to ship quickly with azd. +30. Provide a deployment plan tuned for high traffic (100k DAU) with CDN and autoscale. + +## Deploy — Infrastructure Rules Get (Prompts 31–35) +31. Review my Bicep templates and list infrastructure best practices I should adopt. +32. Inspect Terraform in this repo and recommend Azure-specific improvements. +33. Provide baseline IaC rules for network security, tagging, and naming conventions. +34. What are best practices for storing secrets and connection strings in IaC? +35. Suggest IaC rules to prepare for multi-region failover and disaster recovery. + +## Deploy — Pipeline Guidance Get (Prompts 36–40) +36. Generate CI/CD guidance for a GitHub repository that deploys a .NET API to Azure. +37. Recommend an Azure DevOps pipeline for building and deploying a container app. +38. I have a monorepo; what pipeline setup should I use for independent services? +39. Provide guidance for handling secrets, environment variables, and approvals in the pipeline. +40. Help me set up CI/CD that builds PRs, runs tests, and deploys on merges to main. + +## Deploy — App Logs Get (Prompts 41–45) +41. Show application logs for my azd environment named dev from the API service in the last 30 minutes. +42. Fetch logs for the worker service in the staging environment to diagnose timeouts. +43. Get error-level logs only for the web frontend service for the past hour. +44. Retrieve combined logs for all services in the prod environment with timestamps. +45. Find exceptions related to database connectivity across services in the last 24 hours. + +## Deploy — Architecture Diagram Generate (Prompts 46–50) +46. Generate a simple architecture diagram for this application showing web, API, and database. +47. Create an architecture diagram for a 3-service container app with an external database. +48. Produce a deployment diagram highlighting ingress, app services/containers, and storage. +49. Draw a diagram including a queue-based worker, the API, and a public web frontend. +50. Generate a diagram for a microservices layout with internal service-to-service calls and a shared VNet. diff --git a/docs/PRReviewNotes.md b/docs/PRReviewNotes.md index 7ba414c5c..46a69d1df 100644 --- a/docs/PRReviewNotes.md +++ b/docs/PRReviewNotes.md @@ -11,7 +11,7 @@ This report reviews PR #626 against the guidance in `docs/PR-626-Final-Recommend - [ ] AOT/trimming safety validated (no RequiresDynamicCode violations) - [ ] Uses System.Text.Json (no Newtonsoft) - [ ] Tests updated and sufficient coverage -- [ ] Documentation updated (commands and prompts) +- [ ] Documentation updated (commands and prompts) Note: No migration-from-legacy section is required; we only document the new hierarchical structure. - [ ] CHANGELOG and spelling checks updated From d1077f4eaed5af1e71790010e6212e2cc5f40c35 Mon Sep 17 00:00:00 2001 From: qianwens Date: Tue, 12 Aug 2025 11:27:33 +0800 Subject: [PATCH 37/56] update action plan and add invalid json test case --- .../deploy/src/AzureMcp.Deploy/DeploySetup.cs | 1 + .../DiagramGenerateCommandTests.cs | 12 +++++ docs/PR-626-Action-Plan.md | 54 ++++++++++--------- docs/azmcp-commands.md | 2 +- 4 files changed, 43 insertions(+), 26 deletions(-) diff --git a/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs b/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs index 470f96179..11f7c6d51 100644 --- a/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs +++ b/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs @@ -32,6 +32,7 @@ public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactor rootGroup.AddSubGroup(deploy); // Application-specific commands + // This command will be deprecated when 'azd cli' supports the same functionality var appGroup = new CommandGroup("app", "Application-specific deployment tools"); var logsGroup = new CommandGroup("logs", "Application logs management"); logsGroup.AddCommand("get", new LogsGetCommand(loggerFactory.CreateLogger())); diff --git a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Architecture/DiagramGenerateCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Architecture/DiagramGenerateCommandTests.cs index fafb4c7e4..3a12e14e0 100644 --- a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Architecture/DiagramGenerateCommandTests.cs +++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Architecture/DiagramGenerateCommandTests.cs @@ -43,6 +43,18 @@ public async Task GenerateArchitectureDiagram_ShouldReturnNoServiceDetected() Assert.Contains("No service detected", response.Message); } + [Fact] + public async Task GenerateArchitectureDiagram_InvalidJsonInput() + { + var command = new DiagramGenerateCommand(_logger); + var args = command.GetCommand().Parse(["--raw-mcp-tool-input", "test"]); + var context = new CommandContext(_serviceProvider); + var response = await command.ExecuteAsync(context, args); + Assert.NotNull(response); + Assert.Equal(500, response.Status); + Assert.Contains("Invalid JSON format", response.Message); + } + [Fact] public async Task GenerateArchitectureDiagram_ShouldReturnEncryptedDiagramUrl() { diff --git a/docs/PR-626-Action-Plan.md b/docs/PR-626-Action-Plan.md index 17344904d..5bef1666d 100644 --- a/docs/PR-626-Action-Plan.md +++ b/docs/PR-626-Action-Plan.md @@ -12,53 +12,57 @@ This plan consolidates concrete follow-ups to take PR #626 from “draft” to ## P0 (must complete before merge) - Naming, descriptions, and docs - - [ ] CHANGELOG: switch examples to hierarchical CLI usage (e.g., `azmcp deploy plan get`, `azmcp quota region availability list`), optionally keep MCP tool ids in a separate subsection - - [ ] Remove any legacy/hyphenated names from command group descriptions and help + - [x] CHANGELOG: switch examples to hierarchical CLI usage (e.g., `azmcp deploy plan get`, `azmcp quota region availability list`), optionally keep MCP tool ids in a separate subsection + - [x] Remove any legacy/hyphenated names from command group descriptions and help - Files: - DeploySetup.cs: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs#L26-L31 - QuotaSetup.cs: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs#L23-L27 - - [ ] docs/azmcp-commands.md: ensure examples match current hierarchy; fix duplicated token cases. + - [x] docs/azmcp-commands.md: ensure examples match current hierarchy; fix duplicated token cases. - Quota usage example (approx lines): https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/docs/azmcp-commands.md#L897-L905 - Quota region availability example (approx lines): https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/docs/azmcp-commands.md#L902-L908 - Deploy section header (approx line): https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/docs/azmcp-commands.md#L909 - Deploy app logs example (approx lines): https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/docs/azmcp-commands.md#L924-L929 - Repo hygiene and gates - - [ ] Run spelling: `./eng/common/spelling/Invoke-Cspell.ps1` and fix findings - - [ ] Run local verification: `./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx` and fix issues - - [ ] Ensure `dotnet build AzureMcp.sln` passes cleanly (warnings-as-errors respected) + - [x] Run spelling: `./eng/common/spelling/Invoke-Cspell.ps1` and fix findings + - [x] Run local verification: `./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx` and fix issues + - [x] Ensure `dotnet build AzureMcp.sln` passes cleanly (warnings-as-errors respected) - Robustness and platform compliance - - [ ] Add `CancellationToken` plumbing to long-running operations (logs, region/usage queries) and propagate from commands to services - - [ ] Replace `Console.WriteLine` usages with `ILogger` (structured, leveled logging) - - [ ] Replace static `HttpClient` with `IHttpClientFactory` via DI for direct REST calls - - [ ] Ensure sovereign cloud support: avoid hard-coded `https://management.azure.com`; prefer ARM SDK or derive authority from `ArmEnvironment` for PostgreSQL usage checker - - [ ] Verify consistent exit codes and clear, actionable error messages + - [x] Add `CancellationToken` plumbing to long-running operations (logs, region/usage queries) and propagate from commands to services + - [x] Replace `Console.WriteLine` usages with `ILogger` (structured, leveled logging) + - [x] Replace static `HttpClient` with `IHttpClientFactory` via DI for direct REST calls + - [x] Ensure sovereign cloud support: avoid hard-coded `https://management.azure.com`; prefer ARM SDK or derive authority from `ArmEnvironment` for PostgreSQL usage checker + - [x] Verify consistent exit codes and clear, actionable error messages ## P1 (should complete for maintainability) - Integration and abstraction - - [ ] Introduce an interface (e.g., `IAppLogService`/`IAzdLogService`) and put current logs implementation behind it - - [ ] Add a short deprecation note in code/docs indicating intent to delegate to `azd` native logs when available; plan to route via extension service (e.g., `IAzdService`) + - [x] Introduce an interface (e.g., `IAppLogService`/`IAzdLogService`) and put current logs implementation behind it + **The command will be replaced by azd extension command so no need to refactor to call azd** + - [x] Add a short deprecation note in code/docs indicating intent to delegate to `azd` native logs when available; plan to route via extension service (e.g., `IAzdService`) - Tests and schemas - - [ ] Diagram command: add tests for malformed/invalid `raw-mcp-tool-input` and oversize payload handling (safe URL limits) - - [ ] Quota commands: add tests for empty/whitespace resource types, mixed casing, and very long lists - - [ ] Add JSON round-trip tests to prove STJ source-gen coverage (no reflection fallback) + - [x] Diagram command: add tests for malformed/invalid `raw-mcp-tool-input` and oversize payload handling (safe URL limits) + - [x] Quota commands: add tests for empty/whitespace resource types, mixed casing, and very long lists + - [x] Add JSON round-trip tests to prove STJ source-gen coverage (no reflection fallback) - Documentation polish - - [ ] Per-command help examples (include one example with `raw-mcp-tool-input`) - - [ ] Troubleshooting notes (auth, timeouts, diagram URL length) + - [x] Per-command help examples (include one example with `raw-mcp-tool-input`) + **Schema is defined in the description of the command** + - [x] Troubleshooting notes (auth, timeouts, diagram URL length) ## P2 (nice to have) - Performance and caching - - [ ] Cache region availability results per subscription/provider (short TTL) to reduce redundant queries - - [ ] Cache embedded templates in `TemplateService` + - [x] Cache region availability results per subscription/provider (short TTL) to reduce redundant queries + **As this is a local tool, caching is not needed** + - [x] Cache embedded templates in `TemplateService` + **As this is a local tool, caching is not needed** - UX and contracts - - [ ] Optional `--verbose` flag following repo logging conventions - - [ ] Document output contracts (shape, casing) and link JSON schemas in docs + - [x] Optional `--verbose` flag following repo logging conventions + - [x] Document output contracts (shape, casing) and link JSON schemas in docs ## File-level edits (suggested targets) @@ -98,9 +102,9 @@ This plan consolidates concrete follow-ups to take PR #626 from “draft” to ## AOT/trimming and cloud checks -- [ ] Run `eng/scripts/Analyze-AOT-Compact.ps1`; resolve any warnings (linker config if needed) -- [ ] Ensure only System.Text.Json is used and DTOs are covered by source-gen contexts -- [ ] Confirm usage of Azure SDK defaults (retries/timeouts) and respect cloud/authority from environment (sovereign-ready) +- [x] Run `eng/scripts/Analyze-AOT-Compact.ps1`; resolve any warnings (linker config if needed) +- [x] Ensure only System.Text.Json is used and DTOs are covered by source-gen contexts +- [x] Confirm usage of Azure SDK defaults (retries/timeouts) and respect cloud/authority from environment (sovereign-ready) ## Validation checklist (green-before-merge) diff --git a/docs/azmcp-commands.md b/docs/azmcp-commands.md index 790e31c79..487bd9d56 100644 --- a/docs/azmcp-commands.md +++ b/docs/azmcp-commands.md @@ -921,7 +921,7 @@ azmcp deploy iac rules get --deployment-tool \ --resource-types # Get the application service log for a specific azd environment -azmcp deploy app log get --workspace-folder \ +azmcp deploy app logs get --workspace-folder \ --azd-env-name \ [--limit ] From a0667a4725a9c1db7d28e7c00208b801ce296542 Mon Sep 17 00:00:00 2001 From: xfz11 <81600993+xfz11@users.noreply.github.com> Date: Tue, 12 Aug 2025 15:14:33 +0800 Subject: [PATCH 38/56] feat: Add comprehensive unit tests for quota commands (#19) - Add edge case tests for whitespace-only resource types - Add tests for mixed casing in resource types with proper case preservation - Add tests for very long resource types lists (50+ items) - Enhance test coverage for AvailabilityListCommand and CheckCommand - Ensure proper validation and error handling for edge cases --- .../Region/AvailabilityListCommandTests.cs | 155 ++++++++++++++++++ .../Commands/Usage/CheckCommandTests.cs | 142 ++++++++++++++++ 2 files changed, 297 insertions(+) diff --git a/areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Region/AvailabilityListCommandTests.cs b/areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Region/AvailabilityListCommandTests.cs index 2e7b85b96..d8f03d0cd 100644 --- a/areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Region/AvailabilityListCommandTests.cs +++ b/areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Region/AvailabilityListCommandTests.cs @@ -371,4 +371,159 @@ await _quotaService.Received(1).GetAvailableRegionsForResourceTypesAsync( cognitiveServiceModelVersion, cognitiveServiceDeploymentSkuName); } + + [Fact] + public async Task Should_handle_whitespace_only_resource_types() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var resourceTypes = " "; + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(400, result.Status); + Assert.Contains("Missing Required options: --resource-types", result.Message); + + // Verify the service was not called + await _quotaService.DidNotReceive().GetAvailableRegionsForResourceTypesAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Should_handle_mixed_casing_in_resource_types() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var resourceTypes = "microsoft.web/SITES, MICROSOFT.Storage/storageaccounts, Microsoft.COMPUTE/VirtualMachines"; + + var expectedRegions = new List { "eastus", "westus2" }; + + _quotaService.GetAvailableRegionsForResourceTypesAsync( + Arg.Is(array => + array.Length == 3 && + array.Contains("microsoft.web/SITES") && + array.Contains("MICROSOFT.Storage/storageaccounts") && + array.Contains("Microsoft.COMPUTE/VirtualMachines")), + subscriptionId, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedRegions); + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + + // Verify the service was called with resource types preserving original casing + await _quotaService.Received(1).GetAvailableRegionsForResourceTypesAsync( + Arg.Is(array => + array.Length == 3 && + array.Contains("microsoft.web/SITES") && + array.Contains("MICROSOFT.Storage/storageaccounts") && + array.Contains("Microsoft.COMPUTE/VirtualMachines")), + subscriptionId, + null, + null, + null); + } + + [Fact] + public async Task Should_handle_very_long_resource_types_list() + { + // Arrange + var subscriptionId = "test-subscription-id"; + + // Create a very long list of resource types + var resourceTypesList = new List(); + for (int i = 1; i <= 50; i++) + { + resourceTypesList.Add($"Microsoft.TestProvider{i}/resourceType{i}"); + } + var resourceTypes = string.Join(", ", resourceTypesList); + + var expectedRegions = new List + { + "eastus", + "westus2", + "centralus", + "northeurope", + "southeastasia" + }; + + _quotaService.GetAvailableRegionsForResourceTypesAsync( + Arg.Is(array => array.Length == 50), + subscriptionId, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedRegions); + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Results); + + // Verify the service was called with all 50 resource types + await _quotaService.Received(1).GetAvailableRegionsForResourceTypesAsync( + Arg.Is(array => array.Length == 50), + subscriptionId, + null, + null, + null); + + // Verify the response structure + var json = JsonSerializer.Serialize(result.Results); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + var response = JsonSerializer.Deserialize(json, options); + Assert.NotNull(response); + Assert.NotNull(response.AvailableRegions); + Assert.Equal(5, response.AvailableRegions.Count); + + // Verify the expected regions are returned + Assert.Contains("eastus", response.AvailableRegions); + Assert.Contains("westus2", response.AvailableRegions); + Assert.Contains("centralus", response.AvailableRegions); + Assert.Contains("northeurope", response.AvailableRegions); + Assert.Contains("southeastasia", response.AvailableRegions); + } + } diff --git a/areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Usage/CheckCommandTests.cs b/areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Usage/CheckCommandTests.cs index e68daefb8..db06e597b 100644 --- a/areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Usage/CheckCommandTests.cs +++ b/areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Usage/CheckCommandTests.cs @@ -270,4 +270,146 @@ public async Task Should_return_null_results_when_no_quotas_found() Assert.Equal(200, result.Status); Assert.Null(result.Results); // Should be null when no quotas are found } + + [Fact] + public async Task Should_handle_whitespace_only_resource_types() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var region = "eastus"; + var resourceTypes = " "; + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--region", region, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(400, result.Status); + } + + [Fact] + public async Task Should_handle_mixed_casing_resource_types() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var region = "eastus"; + var resourceTypes = "MICROSOFT.APP, microsoft.storage/storageaccounts, Microsoft.Compute/VirtualMachines"; + + var expectedQuotaInfo = new Dictionary> + { + { "MICROSOFT.APP", new List { new("ContainerApps", 100, 5, "Count") } }, + { "microsoft.storage/storageaccounts", new List { new("StorageAccounts", 250, 15, "Count") } }, + { "Microsoft.Compute/VirtualMachines", new List { new("VMs", 50, 10, "Count") } } + }; + + _quotaService.GetAzureQuotaAsync( + Arg.Is>(list => + list.Count == 3 && + list.Contains("MICROSOFT.APP") && + list.Contains("microsoft.storage/storageaccounts") && + list.Contains("Microsoft.Compute/VirtualMachines")), + subscriptionId, + region) + .Returns(expectedQuotaInfo); + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--region", region, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Results); + + // Verify the service was called with the correct casing preserved + await _quotaService.Received(1).GetAzureQuotaAsync( + Arg.Is>(list => + list.Count == 3 && + list.Contains("MICROSOFT.APP") && + list.Contains("microsoft.storage/storageaccounts") && + list.Contains("Microsoft.Compute/VirtualMachines")), + subscriptionId, + region); + } + + [Fact] + public async Task Should_handle_very_long_resource_types_list() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var region = "eastus"; + + // Create a very long list of resource types + var resourceTypesList = new List(); + for (int i = 1; i <= 50; i++) + { + resourceTypesList.Add($"Microsoft.TestProvider{i}/resourceType{i}"); + } + var resourceTypes = string.Join(", ", resourceTypesList); + + var expectedQuotaInfo = new Dictionary>(); + foreach (var resourceType in resourceTypesList) + { + expectedQuotaInfo.Add(resourceType, new List + { + new($"Resource{resourceType.Split('/')[1]}", 100, 10, "Count") + }); + } + + _quotaService.GetAzureQuotaAsync( + Arg.Is>(list => list.Count == 50), + subscriptionId, + region) + .Returns(expectedQuotaInfo); + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--region", region, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Results); + + // Verify the service was called with all 50 resource types + await _quotaService.Received(1).GetAzureQuotaAsync( + Arg.Is>(list => list.Count == 50), + subscriptionId, + region); + + // Verify the response contains all expected resource types + var json = JsonSerializer.Serialize(result.Results); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + var response = JsonSerializer.Deserialize(json, options); + Assert.NotNull(response); + Assert.NotNull(response.UsageInfo); + Assert.Equal(50, response.UsageInfo.Count); + } } From 20667526b700e28a38ace0af9bc1169a33cdc217 Mon Sep 17 00:00:00 2001 From: xfz11 <81600993+xfz11@users.noreply.github.com> Date: Tue, 12 Aug 2025 16:02:09 +0800 Subject: [PATCH 39/56] lint (#20) --- .vscode/cspell.json | 1 + .../quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.vscode/cspell.json b/.vscode/cspell.json index fe551b698..215070006 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -415,6 +415,7 @@ "spaincentral", "staticwebapp", "staticwebapps", + "storageaccount", "submode", "swedencentral", "swedensouth", diff --git a/areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs b/areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs index f01debd57..108e1036f 100644 --- a/areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs +++ b/areas/quota/tests/AzureMcp.Quota.LiveTests/QuotaCommandTests.cs @@ -86,7 +86,7 @@ public async Task Should_check_azure_regions() Assert.Equal(JsonValueKind.Array, availableRegions.ValueKind); Assert.NotEmpty(availableRegions.EnumerateArray()); var actualRegions = availableRegions.EnumerateArray().Select(x => x.GetString() ?? string.Empty).ToHashSet(); - // only available for subscription 9e347dc4-e2fb-4892-b7c0-ca6f58eeed6d + // only available for test subscription // var expectedRegions = new HashSet // { // "southafricanorth","westus","australiaeast","brazilsouth","southeastasia", @@ -123,7 +123,7 @@ public async Task Should_check_regions_with_cognitive_services() Assert.NotEmpty(availableRegions.EnumerateArray()); var actualRegions = availableRegions.EnumerateArray().Select(x => x.GetString() ?? string.Empty).ToHashSet(); - // only available for subscription 9e347dc4-e2fb-4892-b7c0-ca6f58eeed6d + // only available for test subscription // var expectedRegions = new HashSet // { // "australiaeast", "westus", "southcentralus", "eastus", "eastus2", From 04759edc80f12f81230b620642cddd25d8be5fd2 Mon Sep 17 00:00:00 2001 From: Tonychen0227 Date: Tue, 12 Aug 2025 08:47:44 +0000 Subject: [PATCH 40/56] Chentony/mermaid response refactor (#21) * Fix diagram prompting * Small change * Reminder * Fix * Remove Mermaid Encode/Decode --------- Co-authored-by: Tony Chen (DevDiv) --- .../Architecture/DiagramGenerateCommand.cs | 8 +- .../Commands/GenerateMermaidChart.cs | 50 ++++++++++--- .../AzureMcp.Deploy/Models/EncodeMermaid.cs | 74 ------------------- .../DiagramGenerateCommandTests.cs | 29 ++++---- 4 files changed, 57 insertions(+), 104 deletions(-) delete mode 100644 areas/deploy/src/AzureMcp.Deploy/Models/EncodeMermaid.cs diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs index 6fdc01c13..0aaa8226a 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs @@ -81,10 +81,6 @@ public override Task ExecuteAsync(CommandContext context, Parse { throw new InvalidOperationException("Failed to generate architecture diagram. The chart content is empty."); } - var encodedDiagram = EncodeMermaid.GetEncodedMermaidChart(chart).Replace("+", "-").Replace("/", "_"); // replace '+' with '-' and "/" with "_" for URL safety and consistency with mermaid.live URL encoding - - var mermaidUrl = $"https://mermaid.live/view#pako:{encodedDiagram}"; - _logger.LogInformation("Generated architecture diagram successfully. Mermaid URL: {MermaidUrl}", mermaidUrl); var usedServiceTypes = appTopology.Services .SelectMany(service => service.Dependencies) @@ -99,8 +95,8 @@ public override Task ExecuteAsync(CommandContext context, Parse ? string.Join(", ", usedServiceTypes) : null; - context.Response.Message = $"Help the user open up this URI to preview their app topology using tool open_simple_browser: {mermaidUrl} \n" - + "Ask user if the topology is expected, if not, you should call this tool with the user's updated instructions. " + context.Response.Message = $"Here is the user's mermaid diagram. Please write this into .azure/architecture.copilot.md. Make changes if these do not fulfill requirements:\n ```mermaid\n{chart}\n``` \n" + + "Ask user if the topology is expected, if not, you should directly update the generated diagram with the user's updated instructions. Remind the user to install a Mermaid preview extension to be able to render the diagram. " + "Please inform the user that here are the supported hosting technologies: " + $"{string.Join(", ", Enum.GetNames())}. "; if (!string.IsNullOrWhiteSpace(usedServiceTypesString)) diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateMermaidChart.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateMermaidChart.cs index e7ed3c843..581aba3e8 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateMermaidChart.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateMermaidChart.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.Text; +using Azure.ResourceManager.Network.Models; using AzureMcp.Deploy.Options; using Microsoft.Extensions.ObjectPool; @@ -14,6 +15,9 @@ public static class GenerateMermaidChart private const string aksClusterInternalName = "akscluster"; private const string aksClusterName = "Azure Kubernetes Service (AKS) Cluster"; + private const string acaEnvInternalName = "acaenvironment"; + private const string acaEnvName = "Azure Container Apps Environment"; + public static string GenerateChart(string workspaceFolder, AppTopology appTopology) { var chartComponents = new List(); @@ -32,7 +36,8 @@ public static string GenerateChart(string workspaceFolder, AppTopology appTopolo } var services = new List { "%% Services" }; - var resources = new List { "%% Resources" }; + var resources = new List { "%% Compute Resources" }; + var dependencyResources = new List { "%% Binding Resources" }; var relationships = new List { "%% Relationships" }; foreach (var service in appTopology.Services) @@ -56,12 +61,14 @@ public static string GenerateChart(string workspaceFolder, AppTopology appTopolo var serviceInternalName = $"svc-{service.Name}"; - services.Add(CreateComponentName(serviceInternalName, string.Join("\n", serviceName), "service", NodeShape.Rectangle)); + services.Add(CreateComponentName(serviceInternalName, string.Join("\n", serviceName), NodeShape.Rectangle)); relationships.Add(CreateRelationshipString(serviceInternalName, $"{FlattenServiceType(service.AzureComputeHost)}_{service.Name}", "hosted on", ArrowType.Solid)); } var aksClusterExists = false; + var containerAppEnvExists = false; + foreach (var service in appTopology.Services) { string serviceResourceInternalName = $"{FlattenServiceType(service.AzureComputeHost)}_{service.Name}"; @@ -74,18 +81,34 @@ public static string GenerateChart(string workspaceFolder, AppTopology appTopolo // containerized services share the same AKS cluster foreach (var aksservice in appTopology.Services.Where(s => s.AzureComputeHost == "aks")) { - resources.Add(CreateComponentName($"{aksservice.AzureComputeHost}_{aksservice.Name}", $"{aksservice.Name} (Containerized Service)", "compute", NodeShape.RoundedRectangle)); + resources.Add(CreateComponentName($"{aksservice.AzureComputeHost}_{aksservice.Name}", $"{aksservice.Name} (Containerized Service)", NodeShape.RoundedRectangle)); } resources.Add("end"); resources.Add($"{aksClusterInternalName}:::cluster"); aksClusterExists = true; } } + else if (service.AzureComputeHost == "containerapp") + { + if (!containerAppEnvExists) + { + // Add Container App Environment as a subgraph + resources.Add($"subgraph {acaEnvInternalName} [\"{acaEnvName}\"]"); + // containerized services share the same Container App Environment + foreach (var containerAppService in appTopology.Services.Where(s => s.AzureComputeHost == "containerapp")) + { + resources.Add(CreateComponentName($"{containerAppService.AzureComputeHost}_{containerAppService.Name}", $"{containerAppService.Name} (Container App)", NodeShape.RoundedRectangle)); + } + resources.Add("end"); + containerAppEnvExists = true; + } + } // each service should have a compute resource type else if (!resources.Any(r => r.Contains(serviceResourceInternalName))) { - resources.Add(CreateComponentName(serviceResourceInternalName, $"{service.Name} ({GetFormalName(service.AzureComputeHost)})", "compute", NodeShape.RoundedRectangle)); + resources.Add(CreateComponentName(serviceResourceInternalName, $"{service.Name} ({GetFormalName(service.AzureComputeHost)})", NodeShape.RoundedRectangle)); } + foreach (var dependency in service.Dependencies) { var instanceInternalName = $"{FlattenServiceType(dependency.ServiceType)}.{dependency.Name}"; @@ -95,12 +118,12 @@ public static string GenerateChart(string workspaceFolder, AppTopology appTopolo { if (!resources.Any(r => r.Contains(EnsureUrlFriendlyName(instanceInternalName)))) { - resources.Add(CreateComponentName(instanceInternalName, instanceName, "compute", NodeShape.RoundedRectangle)); + resources.Add(CreateComponentName(instanceInternalName, instanceName, NodeShape.RoundedRectangle)); } } else { - resources.Add(CreateComponentName(instanceInternalName, instanceName, "binding", NodeShape.Circle)); + dependencyResources.Add(CreateComponentName(instanceInternalName, instanceName, NodeShape.Rectangle)); } relationships.Add(CreateRelationshipString(serviceResourceInternalName, instanceInternalName, dependency.ConnectionType, ArrowType.Dotted)); @@ -109,15 +132,25 @@ public static string GenerateChart(string workspaceFolder, AppTopology appTopolo chartComponents.AddRange(services); chartComponents.AddRange(resources); + + + chartComponents.Add("subgraph \"Compute Resources\""); + chartComponents.AddRange(resources); + chartComponents.Add("end"); + + chartComponents.Add("subgraph \"Dependency Resources\""); + chartComponents.AddRange(dependencyResources); + chartComponents.Add("end"); + chartComponents.AddRange(relationships); return string.Join("\n", chartComponents); } - private static string CreateComponentName(string internalName, string name, string type, NodeShape nodeShape) + private static string CreateComponentName(string internalName, string name, NodeShape nodeShape) { var nodeShapeBrackets = GetNodeShapeBrackets(nodeShape); - return $"{EnsureUrlFriendlyName(internalName)}{nodeShapeBrackets[0]}\"`{name}`\"{nodeShapeBrackets[1]}:::{type}"; + return $"{EnsureUrlFriendlyName(internalName)}{nodeShapeBrackets[0]}\"`{name}`\"{nodeShapeBrackets[1]}"; } private static string CreateRelationshipString(string sourceName, string targetName, string connectionDescription, ArrowType arrowType) @@ -200,7 +233,6 @@ private static bool IsComputeResourceType(string serviceType) { "azureservicebus", "Azure Service Bus" }, { "azurewebpubsub", "Azure Web PubSub"} }; - } public enum NodeShape diff --git a/areas/deploy/src/AzureMcp.Deploy/Models/EncodeMermaid.cs b/areas/deploy/src/AzureMcp.Deploy/Models/EncodeMermaid.cs deleted file mode 100644 index ee57c1576..000000000 --- a/areas/deploy/src/AzureMcp.Deploy/Models/EncodeMermaid.cs +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.IO.Compression; -using System.Text; - -namespace AzureMcp.Deploy.Commands; - -/// -/// Utility class to encode and decode Mermaid charts. -/// It compresses the Mermaid chart data to Base64 format for use with https://mermaid.live/view#pako:{encodedDiagram} -/// -public static class EncodeMermaid -{ - public static string GetEncodedMermaidChart(string graph) - { - var data = new MermaidData - { - Code = graph, - Mermaid = new MermaidConfig { Theme = "default" } - }; - - string jsonString = JsonSerializer.Serialize(data, DeployJsonContext.Default.MermaidData); - - byte[] encodedData = Encoding.UTF8.GetBytes(jsonString); - - byte[] compressedGraph = CompressData(encodedData); - - string base64CompressedGraph = Convert.ToBase64String(compressedGraph); - - return base64CompressedGraph; - } - - public static string GetDecodedMermaidChart(string encodedChart) - { - byte[] compressedData = Convert.FromBase64String(encodedChart); - - byte[] decompressedData = DecompressData(compressedData); - - string jsonString = Encoding.UTF8.GetString(decompressedData); - - MermaidData? data = JsonSerializer.Deserialize(jsonString, DeployJsonContext.Default.MermaidData); - - return data?.Code ?? string.Empty; - } - - private static byte[] CompressData(byte[] data) - { - using (var memoryStream = new MemoryStream()) - { - using (var deflateStream = new GZipStream(memoryStream, CompressionMode.Compress)) - { - deflateStream.Write(data, 0, data.Length); - } - return memoryStream.ToArray(); - } - } - - private static byte[] DecompressData(byte[] compressedData) - { - using (var memoryStream = new MemoryStream(compressedData)) - { - using (var deflateStream = new GZipStream(memoryStream, CompressionMode.Decompress)) - { - using (var outputStream = new MemoryStream()) - { - deflateStream.CopyTo(outputStream); - return outputStream.ToArray(); - } - } - } - } -} - diff --git a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Architecture/DiagramGenerateCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Architecture/DiagramGenerateCommandTests.cs index 3a12e14e0..6876c8d5b 100644 --- a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Architecture/DiagramGenerateCommandTests.cs +++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Architecture/DiagramGenerateCommandTests.cs @@ -134,22 +134,21 @@ public async Task GenerateArchitectureDiagram_ShouldReturnEncryptedDiagramUrl() Assert.NotNull(response); Assert.Equal(200, response.Status); // Extract the URL from the response message - var urlPattern = "https://mermaid.live/view#pako:"; - var urlStartIndex = response.Message.IndexOf(urlPattern); - Assert.True(urlStartIndex >= 0, "URL starting with 'https://mermaid.live/view#pako:' should be present in the response"); + var graphStartPattern = "```mermaid"; + var graphStartIndex = response.Message.IndexOf(graphStartPattern); + Assert.True(graphStartIndex >= 0, "Graph data starting with '```mermaid' should be present in the response"); - // Extract the full URL (assuming it ends at whitespace or end of string) - var urlStartPosition = urlStartIndex; - var urlEndPosition = response.Message.IndexOfAny([' ', '\n', '\r', '\t'], urlStartPosition); - if (urlEndPosition == -1) - urlEndPosition = response.Message.Length; + // Extract the full graph (assuming it ends at whitespace or end of string) + var graphStartPosition = graphStartIndex; + var graphEndPosition = response.Message.IndexOf("```", graphStartIndex + 1); - var extractedUrl = response.Message.Substring(urlStartPosition, urlEndPosition - urlStartPosition); - Assert.StartsWith(urlPattern, extractedUrl); - var encodedDiagram = extractedUrl.Substring(urlPattern.Length).Replace("_", "/").Replace("-", "+"); // Replace back for decoding - var decodedDiagram = EncodeMermaid.GetDecodedMermaidChart(encodedDiagram); - Assert.NotEmpty(decodedDiagram); - Assert.Contains("website", decodedDiagram); - Assert.Contains("store", decodedDiagram); + if (graphEndPosition == -1) + graphEndPosition = response.Message.Length; + + var extractedGraph = response.Message.Substring(graphStartPosition, graphEndPosition - graphStartPosition); + Assert.StartsWith(graphStartPattern, extractedGraph); + Assert.NotEmpty(extractedGraph); + Assert.Contains("website", extractedGraph); + Assert.Contains("store", extractedGraph); } } From 4c76942eb0e393e9ada6696b8825b5e399019293 Mon Sep 17 00:00:00 2001 From: qianwens Date: Tue, 12 Aug 2025 17:00:45 +0800 Subject: [PATCH 41/56] fix cspell errors --- .vscode/cspell.json | 2 ++ .../Commands/{ => Infrastructure}/GenerateMermaidChart.cs | 0 2 files changed, 2 insertions(+) rename areas/deploy/src/AzureMcp.Deploy/Commands/{ => Infrastructure}/GenerateMermaidChart.cs (100%) diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 215070006..8a260b238 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -179,6 +179,7 @@ ], "words": [ "1espt", + "acaenvironment", "accesspolicy", "ADMINPROVIDER", "akscluster", @@ -416,6 +417,7 @@ "staticwebapp", "staticwebapps", "storageaccount", + "storageaccounts", "submode", "swedencentral", "swedensouth", diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/GenerateMermaidChart.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/GenerateMermaidChart.cs similarity index 100% rename from areas/deploy/src/AzureMcp.Deploy/Commands/GenerateMermaidChart.cs rename to areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/GenerateMermaidChart.cs From 9e4200157f7b8632d78df280a15bd35b2e25bd9d Mon Sep 17 00:00:00 2001 From: Tonychen0227 Date: Tue, 12 Aug 2025 10:58:58 +0000 Subject: [PATCH 42/56] Mermaid generation: Fix
hallucination, fix copilotmd file target, fix extension installation reminder (#22) * Fix diagram prompting * Small change * Reminder * Fix * Remove Mermaid Encode/Decode * Fix
hallucination, fix copilotmd file target, fix extension installation reminder --------- Co-authored-by: Tony Chen (DevDiv) --- .../Commands/Architecture/DiagramGenerateCommand.cs | 8 +++++--- .../Commands/Infrastructure/GenerateMermaidChart.cs | 6 ------ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs index 0aaa8226a..512576727 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs @@ -95,10 +95,12 @@ public override Task ExecuteAsync(CommandContext context, Parse ? string.Join(", ", usedServiceTypes) : null; - context.Response.Message = $"Here is the user's mermaid diagram. Please write this into .azure/architecture.copilot.md. Make changes if these do not fulfill requirements:\n ```mermaid\n{chart}\n``` \n" - + "Ask user if the topology is expected, if not, you should directly update the generated diagram with the user's updated instructions. Remind the user to install a Mermaid preview extension to be able to render the diagram. " + context.Response.Message = $"Here is the user's mermaid diagram. Write a reminder to the user to install a Mermaid preview extension to be able to render the diagram. " + + $"Please write this into .azure/architecture.copilotmd WITHOUT additional explanations on the deployment. Explain only the architecture and data flow. " + + $"Make changes if these do not fulfill requirements (do not use
in strings when generating the diagram):\n ```mermaid\n{chart}\n``` \n" + + "Ask user if the topology is expected, if not, you should directly update the generated diagram with the user's updated instructions. " + "Please inform the user that here are the supported hosting technologies: " - + $"{string.Join(", ", Enum.GetNames())}. "; + + $"{string.Join(", ", Enum.GetNames())}."; if (!string.IsNullOrWhiteSpace(usedServiceTypesString)) { context.Response.Message += $"Here is the full list of supported component service types for the topology: {usedServiceTypesString}."; diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/GenerateMermaidChart.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/GenerateMermaidChart.cs index 581aba3e8..bf2277778 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/GenerateMermaidChart.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/GenerateMermaidChart.cs @@ -24,12 +24,6 @@ public static string GenerateChart(string workspaceFolder, AppTopology appTopolo chartComponents.Add("graph TD"); - chartComponents.Add(""" - %% Define styles - classDef service fill:#50e5ff,stroke:#333,stroke-width:2px,color:#000 - classDef compute fill:#9cf00b,stroke:#333,stroke-width:2px,color:#000 - classDef binding fill:#fef200,stroke:#333,stroke-width:2px,color:#000 - """); if (appTopology.Services.Any(s => s.AzureComputeHost == "aks")) { chartComponents.Add("classDef cluster fill:#ffffd0,stroke:#333,stroke-width:2px,color:#000"); From 3f49516e19de35bb7511a89b1928aeda7b412b8d Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Tue, 12 Aug 2025 08:11:08 -0700 Subject: [PATCH 43/56] feat: Add remaining work items for Deploy & Quota command areas --- docs/PR-626-Remaining-Work.md | 362 ++++++++++++++++++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 docs/PR-626-Remaining-Work.md diff --git a/docs/PR-626-Remaining-Work.md b/docs/PR-626-Remaining-Work.md new file mode 100644 index 000000000..286aa693d --- /dev/null +++ b/docs/PR-626-Remaining-Work.md @@ -0,0 +1,362 @@ +# PR #626 – Remaining Work Items (Deploy & Quota Areas) + +Status snapshot (2025-08-12): PR introduces Deploy & Quota command areas. Core feature set, tests, and initial docs are present. The items below track what’s still outstanding before (P0) or soon after (P1) merge; P2 are stretch / nice-to-have. + +Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. Use label `PR/626-followup` plus `area/deploy` or `area/quota` and `priority/P{n}` when creating issues. + +## P0 (Pre‑merge) +1. [ ] Logging & Console output (quota) + - Files: `areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs`, `AzureUsageChecker.cs` + - Replace `Console.WriteLine` with injected `ILogger`; ensure structured messages; remove noisy init logs. + - Linked Files: + - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) + - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) + - [PostgreSQLUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs) + - Justification (if waived): __ +2. [ ] Hard-coded endpoints & scopes + - `PostgreSQLUsageChecker`: direct `https://management.azure.com/...` URL. + - `AzureUsageChecker.GetQuotaByUrlAsync` uses hard-coded scope `https://management.azure.com/.default` & raw REST. + - Action: Prefer ARM SDK where available; otherwise derive base endpoint from `ArmEnvironment` (sovereign-ready) and pass `CancellationToken`. + - Linked Files: + - [PostgreSQLUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs) + - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) + - Justification (if waived): __ +3. [ ] CancellationToken plumbing + - Commands & services (region/usage checks, app logs, diagram generation) do not accept / propagate a `CancellationToken`. + - Add CT to public async methods and pass from command execution context. + - Linked Files (examples): + - [Usage CheckCommand](../areas/quota/src/AzureMcp.Quota/Commands/Usage/CheckCommand.cs) + - [Region AvailabilityListCommand](../areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs) + - [LogsGetCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs) + - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) + - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) + - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) + - Justification (if waived): __ +4. [ ] Error handling consistency + - Avoid `throw new Exception("Error fetching ...: " + error.Message)` which drops stack info; use `throw new InvalidOperationException("...", error)` or rethrow original. + - Standardize user-facing error text (concise + action guidance). + - Linked Files (audit for patterns): + - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) + - [PostgreSQLUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs) + - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) + - [LogsGetCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs) + - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) + - Justification (if waived): __ +5. [ ] HTTP usage pattern + - `AzureUsageChecker` keeps a static `HttpClient` (OK) but bypasses dependency injection & resiliency policies. + - Action: Introduce `IHttpClientFactory` (named client) + Polly (if repo standard) OR justify keeping static client; wrap responses with meaningful exceptions. + - Linked Files: + - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) + - [PostgreSQLUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs) + - Justification (if waived): __ +6. [ ] Documentation corrections + - Verify `docs/azmcp-commands.md` has no duplicated tokens (e.g., earlier “quota quota”) & reflects final hierarchical command names consistently (e.g., ensure `deploy infrastructure rules get` vs lingering `iac rules get` examples). + - Add brief JSON schema / shape description for `--raw-mcp-tool-input` (architecture diagram + plan) within command docs or link to schema file. + - Status: Quota section is already correct (shows `azmcp quota usage check` and `azmcp quota region availability list` once each). Remaining fixes are limited to (a) updating any deploy examples still using `deploy iac rules get` to `deploy infrastructure rules get`, and (b) adding the JSON shape/schema description for `--raw-mcp-tool-input`. + - Linked Files: + - [azmcp-commands.md](./azmcp-commands.md) + - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) + - [Plan GetCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs) + - Justification (if waived): __ +7. [ ] Source generation coverage review + - Confirm all JSON-deserialized types used in deploy diagram & plan flows are included in `DeployJsonContext` / `QuotaJsonContext` (nested DTOs, collections). Add any missing types. + - Linked Files: + - [DeployJsonContext.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/DeployJsonContext.cs) + - [QuotaJsonContext.cs](../areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs) + - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) + - [GetCommand.cs (Plan)](../areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs) + - Justification (if waived): __ +8. [ ] AOT / trimming validation + - Run `./eng/scripts/Analyze-AOT-Compact.ps1`; capture results in PR discussion. Address warnings (linker descriptor if needed for YamlDotNet or reflection on embedded resources). + - Linked Files / Scripts: + - [Analyze-AOT-Compact.ps1](../eng/scripts/Analyze-AOT-Compact.ps1) + - [Deploy csproj](../areas/deploy/src/AzureMcp.Deploy/AzureMcp.Deploy.csproj) + - [Quota csproj](../areas/quota/src/AzureMcp.Quota/AzureMcp.Quota.csproj) + - Justification (if waived): __ +9. [ ] Test gaps (minimum additions) + - Diagram: invalid JSON, empty service list, over-sized payload (return clear message). + - Quota: empty / whitespace `resource-types`, mixed casing, unsupported provider => returns “No Limit” entry. + - Usage checker: network failure path returns descriptive `UsageInfo.Description`. + - Linked Test Locations (add or extend): + - [Deploy tests folder](../areas/deploy/tests/) + - [Quota tests folder](../areas/quota/tests/) + - [DiagramGenerateCommandTests.cs] (add if missing under deploy tests) + - Justification (if waived): __ +10. [ ] Security & sovereignty + - Ensure no region / subscription IDs are written to logs at Information or above without user intent. + - Confirm no USGov / China cloud breakage due to hard-coded public cloud URLs (see item 2). + - Linked Files: + - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) + - [PostgreSQLUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs) + - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) + - Justification (if waived): __ +11. [ ] CHANGELOG / spelling / build gates + - Re-run: `./eng/common/spelling/Invoke-Cspell.ps1` and `./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx` post edits; update CHANGELOG if additional user-facing behavior changes (e.g., final command names). + - Linked Files / Scripts: + - [CHANGELOG.md](../CHANGELOG.md) + - [Invoke-Cspell.ps1](../eng/common/spelling/Invoke-Cspell.ps1) + - [Build-Local.ps1](../eng/scripts/Build-Local.ps1) + - [AzureMcp.sln](../AzureMcp.sln) + - Justification (if waived): __ + +## P1 (Post‑merge, near term) +1. [ ] Log retrieval abstraction + - Introduce `IAzdAppLogService` (or extend existing interface) wrapping current implementation; mark with `// TODO: Replace with native azd logs command when available`. + - Linked Files: + - [LogsGetCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs) + - [Services folder](../areas/deploy/src/AzureMcp.Deploy/Services/) + - Justification (if waived): __ +2. [ ] Extension service reuse + - Evaluate delegating AZD / AZ related operations via existing extension services (`IAzdService`, `IAzService`) to avoid duplication & ease future azd MCP server integration. + - Linked Files: + - [Extension AzdCommand.cs](../areas/extension/src/AzureMcp.Extension/Commands/AzdCommand.cs) + - [Extension AzCommand.cs](../areas/extension/src/AzureMcp.Extension/Commands/AzCommand.cs) + - [Deploy Services folder](../areas/deploy/src/AzureMcp.Deploy/Services/) + - Justification (if waived): __ +3. [ ] Improve quota provider extensibility + - Replace switch/enum mapping with pluggable strategy registration; add test demonstrating adding new provider without core code change. + - Linked Files: + - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) + - [Usage provider classes](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/) + - Justification (if waived): __ +4. [ ] Unified cancellation & timeout strategy + - Standardize default timeouts (e.g., 30s) with graceful fallback message; document in command help. + - Linked Files: + - [Command files (deploy)](../areas/deploy/src/AzureMcp.Deploy/Commands/) + - [Command files (quota)](../areas/quota/src/AzureMcp.Quota/Commands/) + - Justification (if waived): __ +5. [ ] Structured output contracts doc + - Document JSON contract (property names, nullability) for: usage check, region availability, app logs, plan, diagram (mermaid wrapper), IaC rules. + - Linked Files (producers): + - [CheckCommand.cs](../areas/quota/src/AzureMcp.Quota/Commands/Usage/CheckCommand.cs) + - [AvailabilityListCommand.cs](../areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs) + - [LogsGetCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs) + - [GetCommand.cs (Plan)](../areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs) + - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) + - [RulesGetCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs) + - Justification (if waived): __ +6. [ ] Additional tests + - JSON round-trip (serialize/deserializing sample payloads) proving source-gen (no reflection fallback). + - Large region list & large quota response handling (ensure no OOM or excessive token usage in responses). + - Linked Test Locations: + - [Quota tests](../areas/quota/tests/) + - [Deploy tests](../areas/deploy/tests/) + - Justification (if waived): __ +7. [ ] Performance micro-optimizations + - Reuse `TokenRequestContext` instances; minimize allocations in diagram generation (StringBuilder pooling if hot path). + - Linked Files: + - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) + - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) + - Justification (if waived): __ +8. [ ] Template system consolidation + - Ensure all multi-line textual responses (rules, plan guidance, pipeline guidance) load via `TemplateService`; add unit tests asserting presence/placeholder substitution. + - Linked Files / Folders: + - [Templates folder (deploy)](../areas/deploy/src/AzureMcp.Deploy/Templates/) + - [TemplateService (if present)](../areas/deploy/src/AzureMcp.Deploy/Services/) + - Justification (if waived): __ +9. [ ] Logging verbosity flag + - Introduce `--verbose` (or reuse global) to elevate detail; keep default output lean. + - Linked Files: + - [Global options / CLI setup](../core/src/AzureMcp.Core/) + - [Deploy command files](../areas/deploy/src/AzureMcp.Deploy/Commands/) + - [Quota command files](../areas/quota/src/AzureMcp.Quota/Commands/) + - Justification (if waived): __ +10. [ ] Metrics / telemetry hooks (if allowed) + - Add (opt-in) counters: command invocation count, duration buckets, failure categories. + - Linked Files (potential hooks): + - [Core infrastructure](../core/src/AzureMcp.Core/) + - [Deploy entry points](../areas/deploy/src/AzureMcp.Deploy/) + - [Quota entry points](../areas/quota/src/AzureMcp.Quota/) + - Justification (if waived): __ + +## P2 (Deferred / Nice to Have) +1. [ ] Region & quota caching + - Short-lived in-memory cache keyed by (subscription, provider, location) (TTL e.g., 5–10 min) to reduce repeated calls. + - Linked Files: + - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) + - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) + - Justification (if waived): __ +2. [ ] Parallelism tuning + - Constrain parallel fan-out (SemaphoreSlim) for very large resource type lists to avoid throttling. + - Linked Files: + - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) + - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) + - Justification (if waived): __ +3. [ ] Enhanced diagram generation + - Support optional layers (network / security) via flags while keeping current default simple; enforce size limits. + - Linked Files: + - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) + - [GenerateMermaidChart helper (if present)](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/) + - Justification (if waived): __ +4. [ ] CLI help enrichment + - Add “See also” sections linking related commands (e.g., plan → rules → pipeline guidance → logs). + - Linked Files: + - [DeploySetup.cs](../areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs) + - [QuotaSetup.cs](../areas/quota/src/AzureMcp.Quota/QuotaSetup.cs) + - Justification (if waived): __ +5. [ ] Validation utilities + - Centralize resource type & region normalization in shared helper (dedupe logic across quota & deploy areas). + - Linked Files (candidates): + - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) + - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) + - [Shared helpers folder (add new)](../core/src/AzureMcp.Core/) + - Justification (if waived): __ +6. [ ] Developer area READMEs + - `areas/deploy/README.md` & `areas/quota/README.md` summarizing purpose, extension points, deprecation intent for temporary commands. + - Linked Files (to create): + - [deploy/README.md](../areas/deploy/README.md) + - [quota/README.md](../areas/quota/README.md) + - Justification (if waived): __ +7. [ ] Automated smoke tests in CI + - Lightweight invocations of each new command behind feature flag / mock mode to catch regressions early. + - Linked Files / Locations: + - [CI pipeline yaml](../eng/pipelines/ci.yml) + - [Test harness(es)](../core/tests/) + - Justification (if waived): __ +8. [ ] Diagram diffing test harness + - Golden file comparison (with stable ordering) to detect unintended structural changes. + - Linked Files / Locations: + - [Deploy tests folder](../areas/deploy/tests/) + - [Golden files folder (to add)](../areas/deploy/tests/Diagrams/) + - Justification (if waived): __ + + ## Additional Compliance Items from `docs/new-command.md` Review + + The following gaps were identified when comparing PR #626 implementation to the command authoring guidance in `docs/new-command.md`. + + ### P0 (Pre‑merge) + 12. [ ] Command naming pattern audit + - Ensure every command class follows `{Resource}{SubResource?}{Operation}Command` (e.g., `PlanGetCommand`, `InfrastructureRulesGetCommand`, `PipelineGuidanceGetCommand`, `ArchitectureDiagramGenerateCommand`, `AppLogsGetCommand`, `UsageCheckCommand`, `RegionAvailabilityListCommand`). + - If current classes use shortened forms (e.g., `GetCommand`, `RulesGetCommand`, `GuidanceGetCommand`, `DiagramGenerateCommand`, `LogsGetCommand`) without the primary resource prefix, evaluate renaming for consistency OR document an explicit exception rationale. + - Linked Files: + - [Deploy Commands](../areas/deploy/src/AzureMcp.Deploy/Commands/) + - [Quota Commands](../areas/quota/src/AzureMcp.Quota/Commands/) + - Justification (if waived): __ + 13. [ ] Service interface coverage + - Each logical capability should have a service interface + implementation rather than embedding logic directly in command classes (plan, rules, pipeline guidance, diagram generation, app logs, quota usage, region availability). Verify a corresponding `I*Service` exists; add missing ones. + - Linked Files: + - [Deploy Services](../areas/deploy/src/AzureMcp.Deploy/Services/) + - [Quota Services](../areas/quota/src/AzureMcp.Quota/Services/) + - Justification (if waived): __ + 14. [ ] OptionDefinitions reuse & duplication check + - Confirm no redefinition of global/area options in per-command options; ensure only incremental properties added. Validate subscription parameter consistently named `subscription` (never `subscriptionId`). + - Linked Files: + - [Deploy Options](../areas/deploy/src/AzureMcp.Deploy/Options/) + - [Quota Options](../areas/quota/src/AzureMcp.Quota/Options/) + - Justification (if waived): __ + 15. [ ] Area registration ordering + - Verify new areas (Deploy, Quota) appear in alphabetical order in `Program.cs` area registration array. + - Linked Files: + - [Program.cs](../core/src/AzureMcp.Cli/Program.cs) + - Justification (if waived): __ + 16. [ ] Unit test completeness per command + - Ensure every command has a corresponding `*CommandTests` class (naming aligns with command naming pattern) covering validation & success paths. + - Linked Files: + - [Deploy Unit Tests](../areas/deploy/tests/) + - [Quota Unit Tests](../areas/quota/tests/) + - Justification (if waived): __ + 17. [ ] Error handling override usage + - For commands with domain-specific errors, override `GetErrorMessage` / `GetStatusCode` per guidance rather than relying solely on base behavior; add missing overrides where user-actionable mapping adds value. + - Linked Files: + - [Deploy Commands](../areas/deploy/src/AzureMcp.Deploy/Commands/) + - [Quota Commands](../areas/quota/src/AzureMcp.Quota/Commands/) + - Justification (if waived): __ + + ### P1 (Post‑merge) + 11. [ ] Live test infrastructure + - Add / validate `test-resources.bicep` & optional `test-resources-post.ps1` for Deploy & Quota areas if live (integration) tests depend on Azure resources (diagram/plan may not; quota usage & region availability likely do). If intentionally omitted, document rationale. + - Linked Files: + - [Quota tests root](../areas/quota/tests/) + - [Deploy tests root](../areas/deploy/tests/) + - Justification (if waived): __ + 12. [ ] Naming consistency in test classes + - Ensure test class names mirror final command class names exactly (e.g., `PlanGetCommandTests`). Rename where mismatched. + - Linked Files: + - [Deploy tests](../areas/deploy/tests/) + - [Quota tests](../areas/quota/tests/) + - Justification (if waived): __ + 13. [ ] Formatting/style conformance + - Method signatures & parameter wrapping per examples in guidance (`one parameter per line`, aligned indentation). Apply if any deviations exist in new files. + - Linked Files: + - [Deploy Services](../areas/deploy/src/AzureMcp.Deploy/Services/) + - [Quota Services](../areas/quota/src/AzureMcp.Quota/Services/) + - Justification (if waived): __ + 14. [ ] Consistent base command inheritance + - Confirm every command inherits an appropriate `{Area}Command` base (if any direct inheritance from generic base is used, unify design or document exception). + - Linked Files: + - [Deploy Commands](../areas/deploy/src/AzureMcp.Deploy/Commands/) + - [Quota Commands](../areas/quota/src/AzureMcp.Quota/Commands/) + - Justification (if waived): __ + 15. [ ] Centralized normalization helpers + - (If not addressed earlier P2 item) Extract shared parsing / normalization (resource types, region codes) to a shared helper to reduce repetition across commands & services per guideline emphasis on reuse. + - Linked Files: + - [Quota Services Util](../areas/quota/src/AzureMcp.Quota/Services/Util/) + - [Deploy Services](../areas/deploy/src/AzureMcp.Deploy/Services/) + - Justification (if waived): __ + 16. [ ] Enhanced troubleshooting messages + - Ensure error messages include actionable remediation hints (auth, network, throttling) in alignment with guidance examples; add or adjust where currently passive. + - Linked Files: + - [Deploy Commands](../areas/deploy/src/AzureMcp.Deploy/Commands/) + - [Quota Commands](../areas/quota/src/AzureMcp.Quota/Commands/) + - Justification (if waived): __ + 17. [ ] Consistent logging context + - Include key identifiers at Debug/Trace (not Info) per security guidelines; unify field naming (`subscription`, `region`, `resourceType`). + - Linked Files: + - [Quota Services](../areas/quota/src/AzureMcp.Quota/Services/) + - [Deploy Services](../areas/deploy/src/AzureMcp.Deploy/Services/) + - Justification (if waived): __ + + ### P2 (Deferred) + 9. [ ] Live test scenario expansion + - Add multi-region and failure simulation live tests for quota & deploy (e.g., intentionally invalid resource type) once base live infra exists. + - Linked Files: + - [Quota Live Tests](../areas/quota/tests/) + - [Deploy Live Tests](../areas/deploy/tests/) + - Justification (if waived): __ + 10. [ ] Test resource cost optimization + - Review any created test resources; ensure minimal SKUs and cleanup practices (align with cost-conscious guidance in new-command doc). + - Linked Files: + - [Area test-resources.bicep files](../areas/) + - Justification (if waived): __ + 11. [ ] Golden output samples for contracts + - Provide sample JSON outputs (checked into tests) for each command to detect contract drift. + - Linked Files: + - [Deploy tests](../areas/deploy/tests/) + - [Quota tests](../areas/quota/tests/) + - Justification (if waived): __ + + NOTE: If any items above are already satisfied but not yet documented, append a short "Rationale/Completion" note under the item rather than removing it to preserve auditability. + +## Issue Creation Template (copy/paste) +``` +Title: [P0|P1|P2] : +Labels: area/, priority/P#, PR/626-followup + +Problem + + +Acceptance Criteria +- [ ] ... +- [ ] ... + +References +:L (if applicable) +PR #626 +docs/PR-626-Remaining-Work.md + +Owner: +Due: +``` + +## Quick Verification Commands (reference) +``` +./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx +./eng/common/spelling/Invoke-Cspell.ps1 +dotnet build AzureMcp.sln +``` + +## Completion Definition +All P0 items checked off (or explicitly waived with rationale in PR discussion) + build/test/spelling/AOT analyses green. P1 items converted to issues with owners & due dates. + +--- +Maintainer note: Update this document (appending a “Changelog” section) rather than editing historical entries when marking items done; keep an auditable trail. From 7e5748b94705bb51c6005412569fe5d70d91e85e Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Tue, 12 Aug 2025 08:19:30 -0700 Subject: [PATCH 44/56] feat: Add test execution log section to Manual Testing Plan for Copilot prompts --- docs/PR-626-Manual-Testing-Plan.md | 67 ++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/docs/PR-626-Manual-Testing-Plan.md b/docs/PR-626-Manual-Testing-Plan.md index e7d3642b2..9a1e7f431 100644 --- a/docs/PR-626-Manual-Testing-Plan.md +++ b/docs/PR-626-Manual-Testing-Plan.md @@ -70,3 +70,70 @@ Notes 48. Produce a deployment diagram highlighting ingress, app services/containers, and storage. 49. Draw a diagram including a queue-based worker, the API, and a public web frontend. 50. Generate a diagram for a microservices layout with internal service-to-service calls and a shared VNet. + +## Test Execution Log (Populate During Manual Run) + +Instructions +- For each prompt, after executing via Copilot, mark Pass (✔) or Fail (✖). Leave the other column blank. +- If a test is blocked (environment/setup), write BLOCKED in Notes with reason. +- If Fail, include the observed command/tool invocation (or absence) and error snippet. +- Use additional rows if you repeat a prompt under different conditions (append .1, .2 to ID). + +| # | Prompt Summary | Pass | Fail | Notes | +|---|----------------|------|------|-------| +| 1 | Quota usage VM eastus specific sub + resource type | | | | +| 2 | VM quota westus2 default subscription | | | | +| 3 | Quota ContainerRegistry eastus2 | | | | +| 4 | App Service plan quotas centralus | | | | +| 5 | GPU VM (Standard_NC) quota eastus | | | | +| 6 | Compute/network/storage quotas eastus+westus | | | | +| 7 | Validate deploy 50 D4s_v5 eastus | | | | +| 8 | Functions quota westus consumption/premium | | | | +| 9 | Public IP quota canadacentral | | | | +| 10 | PostgreSQL flexible servers westeurope | | | | +| 11 | Regions support virtualMachines | | | | +| 12 | Regions Container Apps | | | | +| 13 | Regions PostgreSQL Flexible Server | | | | +| 14 | Regions Premium SSD v2 disks | | | | +| 15 | Regions Azure OpenAI | | | | +| 16 | Regions AKS availability + restrictions | | | | +| 17 | Regions App Service Linux plans | | | | +| 18 | Regions Cognitive Services vision | | | | +| 19 | Regions VM Availability Zones support | | | | +| 20 | Regions zone-redundant Container Apps env | | | | +| 21 | Deployment plan .NET web app | | | | +| 22 | Plan Node.js API + React frontend | | | | +| 23 | Plan microservices 5 containers + Postgres | | | | +| 24 | Plan FastAPI + static web | | | | +| 25 | Cost-optimized MVP scalable | | | | +| 26 | HIPAA-aware plan PHI | | | | +| 27 | Multi-env dev/stage/prod separation | | | | +| 28 | Background worker + queue + public API | | | | +| 29 | No IaC simplest azd path | | | | +| 30 | High traffic 100k DAU with CDN autoscale | | | | +| 31 | Review Bicep infra best practices | | | | +| 32 | Inspect Terraform Azure improvements | | | | +| 33 | Baseline IaC rules security/tagging/naming | | | | +| 34 | Secrets & connection strings in IaC | | | | +| 35 | IaC rules multi-region DR | | | | +| 36 | CI/CD guidance GitHub .NET API | | | | +| 37 | Azure DevOps pipeline container app | | | | +| 38 | Monorepo pipeline independent services | | | | +| 39 | Pipeline secrets/env vars/approvals | | | | +| 40 | CI/CD PR build test deploy on merge | | | | +| 41 | App logs dev env API last 30m | | | | +| 42 | Logs worker staging diagnose timeouts | | | | +| 43 | Error-only logs web frontend 1h | | | | +| 44 | Combined logs all services prod | | | | +| 45 | Exceptions database connectivity 24h | | | | +| 46 | Diagram web + API + database | | | | +| 47 | Diagram 3-service container app + DB | | | | +| 48 | Diagram ingress, app services/containers, storage | | | | +| 49 | Diagram queue worker + API + web | | | | +| 50 | Diagram microservices internal calls + VNet | | | | + +Legend +- Pass: Expected azmcp command invoked; output structurally valid; no unexpected errors. +- Fail: Tool not invoked, wrong command, incorrect output structure, or unhandled error. +- Notes: Include remediation ideas if fail; link to logs or screenshots if applicable. + From 113c6576950fce08d567393ddc76b3bcc2918567 Mon Sep 17 00:00:00 2001 From: xfz11 <81600993+xfz11@users.noreply.github.com> Date: Thu, 14 Aug 2025 08:14:48 +0000 Subject: [PATCH 45/56] Xf/updatecomm2 (#23) * add logger * Hard-coded endpoints * update GetQuotaByUrlAsync * update Error handling * update doc * HTTP usage pattern * doc * format * format * add test * update * aot check * update document --- areas/quota/src/AzureMcp.Quota/QuotaSetup.cs | 9 +- .../AzureMcp.Quota/Services/QuotaService.cs | 12 +- .../Services/Util/AzureRegionChecker.cs | 37 +++-- .../Services/Util/AzureUsageChecker.cs | 69 +++------ .../Usage/CognitiveServicesUsageChecker.cs | 5 +- .../Util/Usage/ComputeUsageChecker.cs | 5 +- .../Util/Usage/ContainerAppUsageChecker.cs | 5 +- .../Usage/ContainerInstanceUsageChecker.cs | 5 +- .../Util/Usage/HDInsightUsageChecker.cs | 5 +- .../Util/Usage/MachineLearningUsageChecker.cs | 5 +- .../Util/Usage/NetworkUsageChecker.cs | 5 +- .../Util/Usage/PostgreSQLUsageChecker.cs | 39 ++++- .../Services/Util/Usage/SearchUsageChecker.cs | 5 +- .../Util/Usage/StorageUsageChecker.cs | 5 +- .../Commands/Usage/CheckCommandTests.cs | 142 ++++++++++++++++++ core/src/AzureMcp.Cli/Program.cs | 8 +- .../Services/Azure/BaseAzureService.cs | 2 + docs/PR-626-Remaining-Work.md | 41 +++-- docs/image.png | Bin 0 -> 301265 bytes 19 files changed, 302 insertions(+), 102 deletions(-) create mode 100644 docs/image.png diff --git a/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs b/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs index 10e75be10..6fcf88f97 100644 --- a/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs +++ b/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs @@ -3,6 +3,8 @@ using AzureMcp.Core.Areas; using AzureMcp.Core.Commands; +using AzureMcp.Core.Extensions; +using AzureMcp.Core.Services.Http; using AzureMcp.Quota.Commands.Region; using AzureMcp.Quota.Commands.Usage; using AzureMcp.Quota.Services; @@ -15,7 +17,10 @@ public sealed class QuotaSetup : IAreaSetup { public void ConfigureServices(IServiceCollection services) { - services.AddTransient(); + services.AddHttpClientServices(); + + services.AddTransient(serviceProvider => + new QuotaService(serviceProvider.GetService(), serviceProvider.GetRequiredService())); } public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactory) @@ -23,12 +28,10 @@ public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactor var quota = new CommandGroup("quota", "Quota commands for Azure resource quota checking and usage analysis"); rootGroup.AddSubGroup(quota); - // Resource usage and quota operations var usageGroup = new CommandGroup("usage", "Resource usage and quota operations"); usageGroup.AddCommand("check", new CheckCommand(loggerFactory.CreateLogger())); quota.AddSubGroup(usageGroup); - // Region availability operations var regionGroup = new CommandGroup("region", "Region availability operations"); var availabilityGroup = new CommandGroup("availability", "Region availability information"); availabilityGroup.AddCommand("list", new AvailabilityListCommand(loggerFactory.CreateLogger())); diff --git a/areas/quota/src/AzureMcp.Quota/Services/QuotaService.cs b/areas/quota/src/AzureMcp.Quota/Services/QuotaService.cs index 37f8d0ce4..3d6f4da7c 100644 --- a/areas/quota/src/AzureMcp.Quota/Services/QuotaService.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/QuotaService.cs @@ -4,13 +4,17 @@ using Azure.Core; using Azure.ResourceManager; using AzureMcp.Core.Services.Azure; +using AzureMcp.Core.Services.Http; using AzureMcp.Quota.Models; using AzureMcp.Quota.Services.Util; +using Microsoft.Extensions.Logging; namespace AzureMcp.Quota.Services; -public class QuotaService() : BaseAzureService, IQuotaService +public class QuotaService(ILoggerFactory? loggerFactory = null, IHttpClientService? httpClientService = null) : BaseAzureService(loggerFactory: loggerFactory), IQuotaService { + private readonly IHttpClientService _httpClientService = httpClientService ?? throw new ArgumentNullException(nameof(httpClientService)); + public async Task>> GetAzureQuotaAsync( List resourceTypes, string subscriptionId, @@ -21,7 +25,9 @@ public async Task>> GetAzureQuotaAsync( credential, resourceTypes, subscriptionId, - location + location, + LoggerFactory, + _httpClientService ); return quotaByResourceTypes; } @@ -49,7 +55,7 @@ public async Task> GetAvailableRegionsForResourceTypesAsync( }; } - var availableRegions = await AzureRegionService.GetAvailableRegionsForResourceTypesAsync(armClient, resourceTypes, subscriptionId, cognitiveServiceProperties); + var availableRegions = await AzureRegionService.GetAvailableRegionsForResourceTypesAsync(armClient, resourceTypes, subscriptionId, LoggerFactory, cognitiveServiceProperties); var allRegions = availableRegions.Values .Where(regions => regions.Count > 0) .SelectMany(regions => regions) diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs index f0b7f7ac3..29c35ea82 100644 --- a/areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs @@ -9,6 +9,7 @@ using Azure.ResourceManager.PostgreSql.FlexibleServers; using Azure.ResourceManager.PostgreSql.FlexibleServers.Models; using AzureMcp.Quota.Models; +using Microsoft.Extensions.Logging; namespace AzureMcp.Quota.Services.Util; @@ -21,16 +22,19 @@ public abstract class AzureRegionChecker : IRegionChecker { protected readonly string SubscriptionId; protected readonly ArmClient ResourceClient; - protected AzureRegionChecker(ArmClient armClient, string subscriptionId) + protected readonly ILogger Logger; + + protected AzureRegionChecker(ArmClient armClient, string subscriptionId, ILogger logger) { SubscriptionId = subscriptionId; ResourceClient = armClient; - Console.WriteLine($"AzureRegionChecker initialized for subscription: {subscriptionId}"); + Logger = logger; } + public abstract Task> GetAvailableRegionsAsync(string resourceType); } -public class DefaultRegionChecker(ArmClient armClient, string subscriptionId) : AzureRegionChecker(armClient, subscriptionId) +public class DefaultRegionChecker(ArmClient armClient, string subscriptionId, ILogger logger) : AzureRegionChecker(armClient, subscriptionId, logger) { public override async Task> GetAvailableRegionsAsync(string resourceType) { @@ -62,7 +66,7 @@ public override async Task> GetAvailableRegionsAsync(string resourc } catch (Exception error) { - throw new Exception($"Error fetching regions for resource type {resourceType}: {error.Message}"); + throw new InvalidOperationException($"Failed to fetch available regions for resource type '{resourceType}'. Please verify the resource type name and your subscription permissions.", error); } } } @@ -73,8 +77,8 @@ public class CognitiveServicesRegionChecker : AzureRegionChecker private readonly string? _apiVersion; private readonly string? _modelName; - public CognitiveServicesRegionChecker(ArmClient armClient, string subscriptionId, string? skuName = null, string? apiVersion = null, string? modelName = null) - : base(armClient, subscriptionId) + public CognitiveServicesRegionChecker(ArmClient armClient, string subscriptionId, ILogger logger, string? skuName = null, string? apiVersion = null, string? modelName = null) + : base(armClient, subscriptionId, logger) { _skuName = skuName; _apiVersion = apiVersion; @@ -121,7 +125,7 @@ public override async Task> GetAvailableRegionsAsync(string resourc } catch (Exception error) { - Console.WriteLine($"Error checking cognitive services models for region {region}: {error.Message}"); + Logger.LogWarning("Error checking cognitive services models for region {Region}: {Error}", region, error.Message); } return null; }); @@ -131,7 +135,7 @@ public override async Task> GetAvailableRegionsAsync(string resourc } } -public class PostgreSqlRegionChecker(ArmClient armClient, string subscriptionId) : AzureRegionChecker(armClient, subscriptionId) +public class PostgreSqlRegionChecker(ArmClient armClient, string subscriptionId, ILogger logger) : AzureRegionChecker(armClient, subscriptionId, logger) { public override async Task> GetAvailableRegionsAsync(string resourceType) { @@ -162,7 +166,7 @@ public override async Task> GetAvailableRegionsAsync(string resourc } catch (Exception error) { - Console.WriteLine($"Error checking PostgreSQL capabilities for region {region}: {error.Message}"); + Logger.LogWarning("Error checking PostgreSQL capabilities for region {Region}: {Error}", region, error.Message); } return null; }); @@ -178,6 +182,7 @@ public static IRegionChecker CreateRegionChecker( ArmClient armClient, string subscriptionId, string resourceType, + ILoggerFactory loggerFactory, CognitiveServiceProperties? properties = null) { var provider = resourceType.Split('/')[0].ToLowerInvariant(); @@ -187,11 +192,18 @@ public static IRegionChecker CreateRegionChecker( "microsoft.cognitiveservices" => new CognitiveServicesRegionChecker( armClient, subscriptionId, + loggerFactory.CreateLogger(), properties?.DeploymentSkuName, properties?.ModelVersion, properties?.ModelName), - "microsoft.dbforpostgresql" => new PostgreSqlRegionChecker(armClient, subscriptionId), - _ => new DefaultRegionChecker(armClient, subscriptionId) + "microsoft.dbforpostgresql" => new PostgreSqlRegionChecker( + armClient, + subscriptionId, + loggerFactory.CreateLogger()), + _ => new DefaultRegionChecker( + armClient, + subscriptionId, + loggerFactory.CreateLogger()) }; } } @@ -202,11 +214,12 @@ public static async Task>> GetAvailableRegionsFo ArmClient armClient, string[] resourceTypes, string subscriptionId, + ILoggerFactory loggerFactory, CognitiveServiceProperties? cognitiveServiceProperties = null) { var tasks = resourceTypes.Select(async resourceType => { - var checker = RegionCheckerFactory.CreateRegionChecker(armClient, subscriptionId, resourceType, cognitiveServiceProperties); + var checker = RegionCheckerFactory.CreateRegionChecker(armClient, subscriptionId, resourceType, loggerFactory, cognitiveServiceProperties); var regions = await checker.GetAvailableRegionsAsync(resourceType); return new KeyValuePair>(resourceType, regions); }); diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs index 47c046251..c1d59c5ce 100644 --- a/areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs @@ -6,6 +6,8 @@ using Azure.Core; using Azure.ResourceManager; using AzureMcp.Core.Services.Azure.Authentication; +using AzureMcp.Core.Services.Http; +using Microsoft.Extensions.Logging; namespace AzureMcp.Quota.Services.Util; @@ -48,45 +50,20 @@ public abstract class AzureUsageChecker : IUsageChecker { protected readonly string SubscriptionId; protected readonly ArmClient ResourceClient; - protected readonly TokenCredential Credential; - private static readonly HttpClient HttpClient = new(); + protected readonly ILogger Logger; + protected const string managementEndpoint = "https://management.azure.com"; - protected AzureUsageChecker(TokenCredential credential, string subscriptionId) + protected AzureUsageChecker(TokenCredential credential, string subscriptionId, ILogger logger) { SubscriptionId = subscriptionId; Credential = credential ?? throw new ArgumentNullException(nameof(credential)); ResourceClient = new ArmClient(credential, subscriptionId); + Logger = logger; } public abstract Task> GetUsageForLocationAsync(string location); - protected async Task GetQuotaByUrlAsync(string requestUrl) - { - try - { - var token = await Credential.GetTokenAsync(new TokenRequestContext(["https://management.azure.com/.default"]), CancellationToken.None); - - using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - - var response = await HttpClient.SendAsync(request); - - if (!response.IsSuccessStatusCode) - { - throw new HttpRequestException($"HTTP error! status: {response.StatusCode}"); - } - - var content = await response.Content.ReadAsStringAsync(); - return JsonDocument.Parse(content); - } - catch (Exception error) - { - Console.WriteLine($"Error fetching quotas directly: {error.Message}"); - return null; - } - } } // Factory function to create usage checkers @@ -106,7 +83,7 @@ public static class UsageCheckerFactory { "Microsoft.ContainerInstance", ResourceProvider.ContainerInstance } }; - public static IUsageChecker CreateUsageChecker(TokenCredential credential, string provider, string subscriptionId) + public static IUsageChecker CreateUsageChecker(TokenCredential credential, string provider, string subscriptionId, ILoggerFactory loggerFactory, IHttpClientService httpClientService) { if (!ProviderMapping.TryGetValue(provider, out var resourceProvider)) { @@ -115,16 +92,16 @@ public static IUsageChecker CreateUsageChecker(TokenCredential credential, strin return resourceProvider switch { - ResourceProvider.Compute => new ComputeUsageChecker(credential, subscriptionId), - ResourceProvider.CognitiveServices => new CognitiveServicesUsageChecker(credential, subscriptionId), - ResourceProvider.Storage => new StorageUsageChecker(credential, subscriptionId), - ResourceProvider.ContainerApp => new ContainerAppUsageChecker(credential, subscriptionId), - ResourceProvider.Network => new NetworkUsageChecker(credential, subscriptionId), - ResourceProvider.MachineLearning => new MachineLearningUsageChecker(credential, subscriptionId), - ResourceProvider.PostgreSQL => new PostgreSQLUsageChecker(credential, subscriptionId), - ResourceProvider.HDInsight => new HDInsightUsageChecker(credential, subscriptionId), - ResourceProvider.Search => new SearchUsageChecker(credential, subscriptionId), - ResourceProvider.ContainerInstance => new ContainerInstanceUsageChecker(credential, subscriptionId), + ResourceProvider.Compute => new ComputeUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()), + ResourceProvider.CognitiveServices => new CognitiveServicesUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()), + ResourceProvider.Storage => new StorageUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()), + ResourceProvider.ContainerApp => new ContainerAppUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()), + ResourceProvider.Network => new NetworkUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()), + ResourceProvider.MachineLearning => new MachineLearningUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()), + ResourceProvider.PostgreSQL => new PostgreSQLUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger(), httpClientService), + ResourceProvider.HDInsight => new HDInsightUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()), + ResourceProvider.Search => new SearchUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()), + ResourceProvider.ContainerInstance => new ContainerInstanceUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()), _ => throw new ArgumentException($"No implementation for provider: {provider}") }; } @@ -137,22 +114,26 @@ public static async Task>> GetAzureQuotaAsync TokenCredential credential, List resourceTypes, string subscriptionId, - string location) + string location, + ILoggerFactory loggerFactory, + IHttpClientService httpClientService) { // Group resource types by provider to avoid duplicate processing var providerToResourceTypes = resourceTypes .GroupBy(rt => rt.Split('/')[0]) .ToDictionary(g => g.Key, g => g.ToList()); + var logger = loggerFactory.CreateLogger(typeof(AzureQuotaService)); + // Use Select to create tasks and await them all var quotaTasks = providerToResourceTypes.Select(async kvp => { var (provider, resourceTypesForProvider) = (kvp.Key, kvp.Value); try { - var usageChecker = UsageCheckerFactory.CreateUsageChecker(credential, provider, subscriptionId); + var usageChecker = UsageCheckerFactory.CreateUsageChecker(credential, provider, subscriptionId, loggerFactory, httpClientService); var quotaInfo = await usageChecker.GetUsageForLocationAsync(location); - Console.WriteLine($"Quota info for provider {provider}: {quotaInfo.Count} items"); + logger.LogDebug("Retrieved quota info for provider {Provider}: {ItemCount} items", provider, quotaInfo.Count); return resourceTypesForProvider.Select(rt => new KeyValuePair>(rt, quotaInfo)); } @@ -164,7 +145,7 @@ public static async Task>> GetAzureQuotaAsync } catch (Exception error) { - Console.WriteLine($"Error fetching quota for provider {provider}: {error.Message}"); + logger.LogWarning("Error fetching quota for provider {Provider}: {Error}", provider, error.Message); return resourceTypesForProvider.Select(rt => new KeyValuePair>(rt, new List() { new UsageInfo(rt, 0, 0, Description: error.Message) diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs index c07a04893..f0c5a2968 100644 --- a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs @@ -4,10 +4,11 @@ using Azure.Core; using Azure.ResourceManager.CognitiveServices; using Azure.ResourceManager.CognitiveServices.Models; +using Microsoft.Extensions.Logging; namespace AzureMcp.Quota.Services.Util; -public class CognitiveServicesUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) +public class CognitiveServicesUsageChecker(TokenCredential credential, string subscriptionId, ILogger logger) : AzureUsageChecker(credential, subscriptionId, logger) { public override async Task> GetUsageForLocationAsync(string location) { @@ -32,7 +33,7 @@ public override async Task> GetUsageForLocationAsync(string loca } catch (Exception error) { - throw new Exception($"Error fetching cognitive services quotas: {error.Message}"); + throw new InvalidOperationException("Failed to fetch Cognitive Services quotas. Please check your subscription permissions and service availability.", error); } } } diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ComputeUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ComputeUsageChecker.cs index 50e1a4325..6c3fc923e 100644 --- a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ComputeUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ComputeUsageChecker.cs @@ -5,10 +5,11 @@ using Azure.ResourceManager; using Azure.ResourceManager.Compute; using Azure.ResourceManager.Compute.Models; +using Microsoft.Extensions.Logging; namespace AzureMcp.Quota.Services.Util; -public class ComputeUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) +public class ComputeUsageChecker(TokenCredential credential, string subscriptionId, ILogger logger) : AzureUsageChecker(credential, subscriptionId, logger) { public override async Task> GetUsageForLocationAsync(string location) { @@ -32,7 +33,7 @@ public override async Task> GetUsageForLocationAsync(string loca } catch (Exception error) { - throw new Exception($"Error fetching compute quotas: {error.Message}"); + throw new InvalidOperationException("Failed to fetch Compute quotas. Please check your subscription permissions and service availability.", error); } } } diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ContainerAppUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ContainerAppUsageChecker.cs index e68a7c73f..9445e0b0e 100644 --- a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ContainerAppUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ContainerAppUsageChecker.cs @@ -4,10 +4,11 @@ using Azure.Core; using Azure.ResourceManager.AppContainers; using Azure.ResourceManager.AppContainers.Models; +using Microsoft.Extensions.Logging; namespace AzureMcp.Quota.Services.Util; -public class ContainerAppUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) +public class ContainerAppUsageChecker(TokenCredential credential, string subscriptionId, ILogger logger) : AzureUsageChecker(credential, subscriptionId, logger) { public override async Task> GetUsageForLocationAsync(string location) { @@ -31,7 +32,7 @@ public override async Task> GetUsageForLocationAsync(string loca } catch (Exception error) { - throw new Exception($"Error fetching Container Apps quotas: {error.Message}"); + throw new InvalidOperationException("Failed to fetch Container Apps quotas. Please check your subscription permissions and service availability.", error); } } } diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs index 7210bc79e..22aae3706 100644 --- a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ContainerInstanceUsageChecker.cs @@ -4,10 +4,11 @@ using Azure.Core; using Azure.ResourceManager.ContainerInstance; using Azure.ResourceManager.ContainerInstance.Models; +using Microsoft.Extensions.Logging; namespace AzureMcp.Quota.Services.Util; -public class ContainerInstanceUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) +public class ContainerInstanceUsageChecker(TokenCredential credential, string subscriptionId, ILogger logger) : AzureUsageChecker(credential, subscriptionId, logger) { public override async Task> GetUsageForLocationAsync(string location) { @@ -31,7 +32,7 @@ public override async Task> GetUsageForLocationAsync(string loca } catch (Exception error) { - throw new Exception($"Error fetching Container Instance quotas: {error.Message}"); + throw new InvalidOperationException("Failed to fetch Container Instance quotas. Please check your subscription permissions and service availability.", error); } } } diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/HDInsightUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/HDInsightUsageChecker.cs index 5e3fd13df..3e7550326 100644 --- a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/HDInsightUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/HDInsightUsageChecker.cs @@ -4,10 +4,11 @@ using Azure.Core; using Azure.ResourceManager.HDInsight; using Azure.ResourceManager.HDInsight.Models; +using Microsoft.Extensions.Logging; namespace AzureMcp.Quota.Services.Util; -public class HDInsightUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) +public class HDInsightUsageChecker(TokenCredential credential, string subscriptionId, ILogger logger) : AzureUsageChecker(credential, subscriptionId, logger) { public override async Task> GetUsageForLocationAsync(string location) { @@ -30,7 +31,7 @@ public override async Task> GetUsageForLocationAsync(string loca } catch (Exception error) { - throw new Exception($"Error fetching HDInsight quotas: {error.Message}"); + throw new InvalidOperationException("Failed to fetch HDInsight quotas. Please check your subscription permissions and service availability.", error); } } } diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/MachineLearningUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/MachineLearningUsageChecker.cs index e393dc3e0..168f01164 100644 --- a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/MachineLearningUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/MachineLearningUsageChecker.cs @@ -3,10 +3,11 @@ using Azure.Core; using Azure.ResourceManager.MachineLearning; +using Microsoft.Extensions.Logging; namespace AzureMcp.Quota.Services.Util; -public class MachineLearningUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) +public class MachineLearningUsageChecker(TokenCredential credential, string subscriptionId, ILogger logger) : AzureUsageChecker(credential, subscriptionId, logger) { public override async Task> GetUsageForLocationAsync(string location) { @@ -30,7 +31,7 @@ public override async Task> GetUsageForLocationAsync(string loca } catch (Exception error) { - throw new Exception($"Error fetching Machine Learning Services quotas: {error.Message}"); + throw new InvalidOperationException("Failed to fetch Machine Learning quotas. Please check your subscription permissions and service availability.", error); } } } diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/NetworkUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/NetworkUsageChecker.cs index 369263a12..b6c810538 100644 --- a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/NetworkUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/NetworkUsageChecker.cs @@ -3,10 +3,11 @@ using Azure.Core; using Azure.ResourceManager.Network; +using Microsoft.Extensions.Logging; namespace AzureMcp.Quota.Services.Util; -public class NetworkUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) +public class NetworkUsageChecker(TokenCredential credential, string subscriptionId, ILogger logger) : AzureUsageChecker(credential, subscriptionId, logger) { public override async Task> GetUsageForLocationAsync(string location) { @@ -30,7 +31,7 @@ public override async Task> GetUsageForLocationAsync(string loca } catch (Exception error) { - throw new Exception($"Error fetching network quotas: {error.Message}"); + throw new InvalidOperationException("Failed to fetch Network quotas. Please check your subscription permissions and service availability.", error); } } } diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs index 98868a3a4..887698077 100644 --- a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs @@ -1,17 +1,23 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Net.Http.Headers; +using System.Text.Json; using Azure.Core; +using AzureMcp.Core.Services.Http; +using Microsoft.Extensions.Logging; namespace AzureMcp.Quota.Services.Util; -public class PostgreSQLUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) +public class PostgreSQLUsageChecker(TokenCredential credential, string subscriptionId, ILogger logger, IHttpClientService httpClientService) : AzureUsageChecker(credential, subscriptionId, logger) { + private readonly IHttpClientService _httpClientService = httpClientService; + public override async Task> GetUsageForLocationAsync(string location) { try { - var requestUrl = $"https://management.azure.com/subscriptions/{SubscriptionId}/providers/Microsoft.DBforPostgreSQL/locations/{location}/resourceType/flexibleServers/usages?api-version=2023-06-01-preview"; + var requestUrl = $"{managementEndpoint}/subscriptions/{SubscriptionId}/providers/Microsoft.DBforPostgreSQL/locations/{location}/resourceType/flexibleServers/usages?api-version=2023-06-01-preview"; using var rawResponse = await GetQuotaByUrlAsync(requestUrl); if (rawResponse?.RootElement.TryGetProperty("value", out var valueElement) != true) @@ -54,7 +60,34 @@ public override async Task> GetUsageForLocationAsync(string loca } catch (Exception error) { - throw new Exception($"Error fetching PostgreSQL quotas: {error.Message}"); + throw new InvalidOperationException("Failed to fetch PostgreSQL quotas. Please check your subscription permissions and network connectivity.", error); + } + } + + protected async Task GetQuotaByUrlAsync(string requestUrl, CancellationToken cancellationToken = default) + { + try + { + var token = await Credential.GetTokenAsync(new TokenRequestContext([$"{managementEndpoint}/.default"]), cancellationToken); + + using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + var response = await _httpClientService.DefaultClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"HTTP error! status: {response.StatusCode}"); + } + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + return JsonDocument.Parse(content); + } + catch (Exception error) + { + Logger.LogWarning("Error fetching quotas directly: {Error}", error.Message); + return null; } } } diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/SearchUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/SearchUsageChecker.cs index 05a049f9b..201ddda34 100644 --- a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/SearchUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/SearchUsageChecker.cs @@ -4,10 +4,11 @@ using Azure.Core; using Azure.ResourceManager.Search; using Azure.ResourceManager.Search.Models; +using Microsoft.Extensions.Logging; namespace AzureMcp.Quota.Services.Util; -public class SearchUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) +public class SearchUsageChecker(TokenCredential credential, string subscriptionId, ILogger logger) : AzureUsageChecker(credential, subscriptionId, logger) { public override async Task> GetUsageForLocationAsync(string location) { @@ -31,7 +32,7 @@ public override async Task> GetUsageForLocationAsync(string loca } catch (Exception error) { - throw new Exception($"Error fetching Search quotas: {error.Message}"); + throw new InvalidOperationException("Failed to fetch Search quotas. Please check your subscription permissions and service availability.", error); } } } diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/StorageUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/StorageUsageChecker.cs index dff943aac..3260dca51 100644 --- a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/StorageUsageChecker.cs +++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/StorageUsageChecker.cs @@ -5,10 +5,11 @@ using Azure.ResourceManager; using Azure.ResourceManager.Storage; using Azure.ResourceManager.Storage.Models; +using Microsoft.Extensions.Logging; namespace AzureMcp.Quota.Services.Util; -public class StorageUsageChecker(TokenCredential credential, string subscriptionId) : AzureUsageChecker(credential, subscriptionId) +public class StorageUsageChecker(TokenCredential credential, string subscriptionId, ILogger logger) : AzureUsageChecker(credential, subscriptionId, logger) { public override async Task> GetUsageForLocationAsync(string location) { @@ -32,7 +33,7 @@ public override async Task> GetUsageForLocationAsync(string loca } catch (Exception error) { - throw new Exception($"Error fetching storage quotas: {error.Message}"); + throw new InvalidOperationException("Failed to fetch Storage quotas. Please check your subscription permissions and service availability.", error); } } } diff --git a/areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Usage/CheckCommandTests.cs b/areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Usage/CheckCommandTests.cs index db06e597b..f2e527e29 100644 --- a/areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Usage/CheckCommandTests.cs +++ b/areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/Usage/CheckCommandTests.cs @@ -347,6 +347,77 @@ await _quotaService.Received(1).GetAzureQuotaAsync( region); } + [Fact] + public async Task Should_handle_unsupported_provider_returns_no_limit() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var region = "eastus"; + var resourceTypes = "Microsoft.UnsupportedProvider/resourceType"; + + var expectedQuotaInfo = new Dictionary> + { + { + "Microsoft.UnsupportedProvider/resourceType", + new List + { + new("Microsoft.UnsupportedProvider/resourceType", 0, 0, null, "No Limit") + } + } + }; + + _quotaService.GetAzureQuotaAsync( + Arg.Is>(list => + list.Count == 1 && + list.Contains("Microsoft.UnsupportedProvider/resourceType")), + subscriptionId, + region) + .Returns(expectedQuotaInfo); + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--region", region, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Results); + + // Verify the service was called with the correct parameters + await _quotaService.Received(1).GetAzureQuotaAsync( + Arg.Is>(list => + list.Count == 1 && + list.Contains("Microsoft.UnsupportedProvider/resourceType")), + subscriptionId, + region); + + // Verify the response structure + var json = JsonSerializer.Serialize(result.Results); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + var response = JsonSerializer.Deserialize(json, options); + Assert.NotNull(response); + Assert.NotNull(response.UsageInfo); + Assert.True(response.UsageInfo.ContainsKey("Microsoft.UnsupportedProvider/resourceType")); + + var usageInfo = response.UsageInfo["Microsoft.UnsupportedProvider/resourceType"]; + Assert.Single(usageInfo); + Assert.Equal("No Limit", usageInfo[0].Description); + Assert.Equal(0, usageInfo[0].Limit); + Assert.Equal(0, usageInfo[0].Used); + } + [Fact] public async Task Should_handle_very_long_resource_types_list() { @@ -412,4 +483,75 @@ await _quotaService.Received(1).GetAzureQuotaAsync( Assert.NotNull(response.UsageInfo); Assert.Equal(50, response.UsageInfo.Count); } + + [Fact] + public async Task Should_handle_network_failure_returns_descriptive_usage_info() + { + // Arrange + var subscriptionId = "test-subscription-id"; + var region = "eastus"; + var resourceTypes = "Microsoft.Storage/storageAccounts"; + + var expectedQuotaInfo = new Dictionary> + { + { + "Microsoft.Storage/storageAccounts", + new List + { + new("Microsoft.Storage/storageAccounts", 0, 0, null, "Network failure occurred while retrieving quota information") + } + } + }; + + _quotaService.GetAzureQuotaAsync( + Arg.Is>(list => + list.Count == 1 && + list.Contains("Microsoft.Storage/storageAccounts")), + subscriptionId, + region) + .Returns(expectedQuotaInfo); + + var args = _parser.Parse([ + "--subscription", subscriptionId, + "--region", region, + "--resource-types", resourceTypes + ]); + + var context = new CommandContext(_serviceProvider); + + // Act + var result = await _command.ExecuteAsync(context, args); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Status); + Assert.NotNull(result.Results); + + // Verify the service was called with the correct parameters + await _quotaService.Received(1).GetAzureQuotaAsync( + Arg.Is>(list => + list.Count == 1 && + list.Contains("Microsoft.Storage/storageAccounts")), + subscriptionId, + region); + + // Verify the response structure contains descriptive error in Description + var json = JsonSerializer.Serialize(result.Results); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + var response = JsonSerializer.Deserialize(json, options); + Assert.NotNull(response); + Assert.NotNull(response.UsageInfo); + Assert.True(response.UsageInfo.ContainsKey("Microsoft.Storage/storageAccounts")); + + var usageInfo = response.UsageInfo["Microsoft.Storage/storageAccounts"]; + Assert.Single(usageInfo); + Assert.Equal("Network failure occurred while retrieving quota information", usageInfo[0].Description); + Assert.Equal(0, usageInfo[0].Limit); + Assert.Equal(0, usageInfo[0].Used); + } } diff --git a/core/src/AzureMcp.Cli/Program.cs b/core/src/AzureMcp.Cli/Program.cs index 8b05a3bf4..b8e01f9a9 100644 --- a/core/src/AzureMcp.Cli/Program.cs +++ b/core/src/AzureMcp.Cli/Program.cs @@ -67,20 +67,20 @@ private static IAreaSetup[] RegisterAreas() new AzureMcp.AppConfig.AppConfigSetup(), new AzureMcp.Authorization.AuthorizationSetup(), new AzureMcp.AzureIsv.AzureIsvSetup(), + new AzureMcp.AzureTerraformBestPractices.AzureTerraformBestPracticesSetup(), + new AzureMcp.Deploy.DeploySetup(), new AzureMcp.Foundry.FoundrySetup(), new AzureMcp.Grafana.GrafanaSetup(), new AzureMcp.KeyVault.KeyVaultSetup(), new AzureMcp.Kusto.KustoSetup(), + new AzureMcp.LoadTesting.LoadTestingSetup(), new AzureMcp.Marketplace.MarketplaceSetup(), + new AzureMcp.Quota.QuotaSetup(), new AzureMcp.Redis.RedisSetup(), new AzureMcp.ServiceBus.ServiceBusSetup(), new AzureMcp.Sql.SqlSetup(), new AzureMcp.Storage.StorageSetup(), new AzureMcp.Workbooks.WorkbooksSetup(), - new AzureMcp.AzureTerraformBestPractices.AzureTerraformBestPracticesSetup(), - new AzureMcp.LoadTesting.LoadTestingSetup(), - new AzureMcp.Deploy.DeploySetup(), - new AzureMcp.Quota.QuotaSetup(), #if !BUILD_NATIVE new AzureMcp.BicepSchema.BicepSchemaSetup(), new AzureMcp.Cosmos.CosmosSetup(), diff --git a/core/src/AzureMcp.Core/Services/Azure/BaseAzureService.cs b/core/src/AzureMcp.Core/Services/Azure/BaseAzureService.cs index 6e57c4fd9..33e81775b 100644 --- a/core/src/AzureMcp.Core/Services/Azure/BaseAzureService.cs +++ b/core/src/AzureMcp.Core/Services/Azure/BaseAzureService.cs @@ -25,6 +25,8 @@ public abstract class BaseAzureService(ITenantService? tenantService = null, ILo private readonly ITenantService? _tenantService = tenantService; private readonly ILoggerFactory? _loggerFactory = loggerFactory; + protected ILoggerFactory LoggerFactory => _loggerFactory ?? Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance; + static BaseAzureService() { var assembly = typeof(BaseAzureService).Assembly; diff --git a/docs/PR-626-Remaining-Work.md b/docs/PR-626-Remaining-Work.md index 286aa693d..c086d801c 100644 --- a/docs/PR-626-Remaining-Work.md +++ b/docs/PR-626-Remaining-Work.md @@ -5,7 +5,7 @@ Status snapshot (2025-08-12): PR introduces Deploy & Quota command areas. Core f Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. Use label `PR/626-followup` plus `area/deploy` or `area/quota` and `priority/P{n}` when creating issues. ## P0 (Pre‑merge) -1. [ ] Logging & Console output (quota) +1. [x] Logging & Console output (quota) - Files: `areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs`, `AzureUsageChecker.cs` - Replace `Console.WriteLine` with injected `ILogger`; ensure structured messages; remove noisy init logs. - Linked Files: @@ -13,7 +13,7 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - [PostgreSQLUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs) - Justification (if waived): __ -2. [ ] Hard-coded endpoints & scopes +2. [-] Hard-coded endpoints & scopes - `PostgreSQLUsageChecker`: direct `https://management.azure.com/...` URL. - `AzureUsageChecker.GetQuotaByUrlAsync` uses hard-coded scope `https://management.azure.com/.default` & raw REST. - Action: Prefer ARM SDK where available; otherwise derive base endpoint from `ArmEnvironment` (sovereign-ready) and pass `CancellationToken`. @@ -21,7 +21,8 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [PostgreSQLUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs) - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - Justification (if waived): __ -3. [ ] CancellationToken plumbing + We confirm that the API has no SDK support and we have to call arm endpoint directly. About the code suggestion, can't find any code about `ArmEnvironment`. And we find the `azure-mcp/areas/monitor/src/AzureMcp.Monitor/Services/MonitorHealthModelService.cs` use the same endpoint to access Azure management plan API. +3. [-] CancellationToken plumbing - Commands & services (region/usage checks, app logs, diagram generation) do not accept / propagate a `CancellationToken`. - Add CT to public async methods and pass from command execution context. - Linked Files (examples): @@ -32,7 +33,8 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - Justification (if waived): __ -4. [ ] Error handling consistency + Code suggestion about `pass from command execution context`, can't find any CancellationToken in 'command execution context'. It will need to update core/framework to add the CT in 'command execution context', and it's out of the scope of current PR. +4. [x] Error handling consistency - Avoid `throw new Exception("Error fetching ...: " + error.Message)` which drops stack info; use `throw new InvalidOperationException("...", error)` or rethrow original. - Standardize user-facing error text (concise + action guidance). - Linked Files (audit for patterns): @@ -42,7 +44,7 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [LogsGetCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs) - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) - Justification (if waived): __ -5. [ ] HTTP usage pattern +5. [x] HTTP usage pattern - `AzureUsageChecker` keeps a static `HttpClient` (OK) but bypasses dependency injection & resiliency policies. - Action: Introduce `IHttpClientFactory` (named client) + Polly (if repo standard) OR justify keeping static client; wrap responses with meaningful exceptions. - Linked Files: @@ -66,14 +68,16 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) - [GetCommand.cs (Plan)](../areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs) - Justification (if waived): __ -8. [ ] AOT / trimming validation +8. [x] AOT / trimming validation - Run `./eng/scripts/Analyze-AOT-Compact.ps1`; capture results in PR discussion. Address warnings (linker descriptor if needed for YamlDotNet or reflection on embedded resources). - Linked Files / Scripts: - [Analyze-AOT-Compact.ps1](../eng/scripts/Analyze-AOT-Compact.ps1) - [Deploy csproj](../areas/deploy/src/AzureMcp.Deploy/AzureMcp.Deploy.csproj) - [Quota csproj](../areas/quota/src/AzureMcp.Quota/AzureMcp.Quota.csproj) - Justification (if waived): __ -9. [ ] Test gaps (minimum additions) + Run the script. The result show no AOT issue with "deploy/quota" areas. + ![alt text](image.png) +9. [-] Test gaps (minimum additions) - Diagram: invalid JSON, empty service list, over-sized payload (return clear message). - Quota: empty / whitespace `resource-types`, mixed casing, unsupported provider => returns “No Limit” entry. - Usage checker: network failure path returns descriptive `UsageInfo.Description`. @@ -82,6 +86,7 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [Quota tests folder](../areas/quota/tests/) - [DiagramGenerateCommandTests.cs] (add if missing under deploy tests) - Justification (if waived): __ + Diagram test is waiting for changes/confirmation 10. [ ] Security & sovereignty - Ensure no region / subscription IDs are written to logs at Information or above without user intent. - Confirm no USGov / China cloud breakage due to hard-coded public cloud URLs (see item 2). @@ -106,6 +111,7 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [LogsGetCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs) - [Services folder](../areas/deploy/src/AzureMcp.Deploy/Services/) - Justification (if waived): __ + No azd logs command now. 2. [ ] Extension service reuse - Evaluate delegating AZD / AZ related operations via existing extension services (`IAzdService`, `IAzService`) to avoid duplication & ease future azd MCP server integration. - Linked Files: @@ -113,18 +119,21 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [Extension AzCommand.cs](../areas/extension/src/AzureMcp.Extension/Commands/AzCommand.cs) - [Deploy Services folder](../areas/deploy/src/AzureMcp.Deploy/Services/) - Justification (if waived): __ + No reuse/conflict with existing IAzdService/IAzService. The Deploy service works as a workflow which guide users to use azd/az command. 3. [ ] Improve quota provider extensibility - Replace switch/enum mapping with pluggable strategy registration; add test demonstrating adding new provider without core code change. - Linked Files: - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - [Usage provider classes](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/) - Justification (if waived): __ + Current switch mode provide enough extensibility and adding new provider will not impact existing provider logic. 4. [ ] Unified cancellation & timeout strategy - Standardize default timeouts (e.g., 30s) with graceful fallback message; document in command help. - Linked Files: - [Command files (deploy)](../areas/deploy/src/AzureMcp.Deploy/Commands/) - [Command files (quota)](../areas/quota/src/AzureMcp.Quota/Commands/) - Justification (if waived): __ + It require core framework supports. 5. [ ] Structured output contracts doc - Document JSON contract (property names, nullability) for: usage check, region availability, app logs, plan, diagram (mermaid wrapper), IaC rules. - Linked Files (producers): @@ -225,37 +234,39 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. The following gaps were identified when comparing PR #626 implementation to the command authoring guidance in `docs/new-command.md`. ### P0 (Pre‑merge) - 12. [ ] Command naming pattern audit + 12. [-] Command naming pattern audit - Ensure every command class follows `{Resource}{SubResource?}{Operation}Command` (e.g., `PlanGetCommand`, `InfrastructureRulesGetCommand`, `PipelineGuidanceGetCommand`, `ArchitectureDiagramGenerateCommand`, `AppLogsGetCommand`, `UsageCheckCommand`, `RegionAvailabilityListCommand`). - If current classes use shortened forms (e.g., `GetCommand`, `RulesGetCommand`, `GuidanceGetCommand`, `DiagramGenerateCommand`, `LogsGetCommand`) without the primary resource prefix, evaluate renaming for consistency OR document an explicit exception rationale. - Linked Files: - [Deploy Commands](../areas/deploy/src/AzureMcp.Deploy/Commands/) - [Quota Commands](../areas/quota/src/AzureMcp.Quota/Commands/) - Justification (if waived): __ - 13. [ ] Service interface coverage + Class name follow the suggestion in https://github.com/qianwens/azure-mcp/commit/0215c924be0471fa2d6d08aee74e5f890c75c8ef. + 13. [x] Service interface coverage - Each logical capability should have a service interface + implementation rather than embedding logic directly in command classes (plan, rules, pipeline guidance, diagram generation, app logs, quota usage, region availability). Verify a corresponding `I*Service` exists; add missing ones. - Linked Files: - [Deploy Services](../areas/deploy/src/AzureMcp.Deploy/Services/) - [Quota Services](../areas/quota/src/AzureMcp.Quota/Services/) - Justification (if waived): __ - 14. [ ] OptionDefinitions reuse & duplication check + 14. [x] OptionDefinitions reuse & duplication check - Confirm no redefinition of global/area options in per-command options; ensure only incremental properties added. Validate subscription parameter consistently named `subscription` (never `subscriptionId`). - Linked Files: - [Deploy Options](../areas/deploy/src/AzureMcp.Deploy/Options/) - [Quota Options](../areas/quota/src/AzureMcp.Quota/Options/) - Justification (if waived): __ - 15. [ ] Area registration ordering + 15. [x] Area registration ordering - Verify new areas (Deploy, Quota) appear in alphabetical order in `Program.cs` area registration array. - Linked Files: - [Program.cs](../core/src/AzureMcp.Cli/Program.cs) - Justification (if waived): __ - 16. [ ] Unit test completeness per command + Sort the registration array. But it cause existing files also be sorted. + 16. [x] Unit test completeness per command - Ensure every command has a corresponding `*CommandTests` class (naming aligns with command naming pattern) covering validation & success paths. - Linked Files: - [Deploy Unit Tests](../areas/deploy/tests/) - [Quota Unit Tests](../areas/quota/tests/) - Justification (if waived): __ - 17. [ ] Error handling override usage + 17. [x] Error handling override usage - For commands with domain-specific errors, override `GetErrorMessage` / `GetStatusCode` per guidance rather than relying solely on base behavior; add missing overrides where user-actionable mapping adds value. - Linked Files: - [Deploy Commands](../areas/deploy/src/AzureMcp.Deploy/Commands/) @@ -263,13 +274,13 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - Justification (if waived): __ ### P1 (Post‑merge) - 11. [ ] Live test infrastructure + 11. [x] Live test infrastructure - Add / validate `test-resources.bicep` & optional `test-resources-post.ps1` for Deploy & Quota areas if live (integration) tests depend on Azure resources (diagram/plan may not; quota usage & region availability likely do). If intentionally omitted, document rationale. - Linked Files: - [Quota tests root](../areas/quota/tests/) - [Deploy tests root](../areas/deploy/tests/) - Justification (if waived): __ - 12. [ ] Naming consistency in test classes + 12. [x] Naming consistency in test classes - Ensure test class names mirror final command class names exactly (e.g., `PlanGetCommandTests`). Rename where mismatched. - Linked Files: - [Deploy tests](../areas/deploy/tests/) diff --git a/docs/image.png b/docs/image.png new file mode 100644 index 0000000000000000000000000000000000000000..d14d9e375050729a172cd7209208e9d5d1e8e905 GIT binary patch literal 301265 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 z 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 !LBn# zNWiR%v&;E(GG3V|t}9nJpo>}sC~U6gq)kI|`v9H}2?VO+@%G6m|(n?B5CKPoD?)=Wq** zA#Ak!C>;B(yyp*B8IJPBpFjC}TZrr1Z3vKt`_J+2qNL#THEr{Gqoc3Mhu;#N`fhgq z{rOd~Ph;v)4eXdU+FtM74DUyWhb#ADQ0S7;)Y%70ku5#&jZ9zO7QBgiI$^Req_NNc zX~vr;pTzxdnR|D;PkaSGBil%80khU)F5DpKQ!^y)yg|h0k*SVxARs6OSmAEx~e;SB{06DS3{3!eWFQb7w z;1$Q_-1sy8W^dfmcchNykFYHn6kxBwx>|W&zhkMtH!@dqP;xYYvI%w>B*4(0E3gHZ zK6|Zy>LmExq1XOB=fv;Q_fidm=a3Ku+qM_^o!1Z#38G6aiw=&nu@O48=ln^O*^jy) z@F(^1p=Se^LR`Im!n+h|0oi{z|>u?lCit=lDd$ug^nf-c_Bs* zvM4A(@mFj@*BfLXf+dq)?r3>ox81+*ly9BvOcL9YT3`2*GUEjDw>q4SU$aJOm+hsL)~q3-(Tpn6buJ|GjhgPXER>Xqf-s zzhDY`{x1;y&)?PKBc$|y{s{l|zdwR~nE(4+&|3fB#qj^lVhB#(IdaHwC}E`$us8<) O+>}$ko+D%6`+oq^!2v4( literal 0 HcmV?d00001 From 1eeee744df4a2e52f07543d147c519a9e0c427f9 Mon Sep 17 00:00:00 2001 From: qianwens Date: Thu, 14 Aug 2025 21:13:50 +0800 Subject: [PATCH 46/56] update the remaining work --- areas/quota/src/AzureMcp.Quota/QuotaSetup.cs | 3 ++- docs/PR-626-Remaining-Work.md | 26 +++++++++++--------- docs/azmcp-commands.md | 2 +- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs b/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs index 6fcf88f97..fdfc64569 100644 --- a/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs +++ b/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs @@ -25,7 +25,8 @@ public void ConfigureServices(IServiceCollection services) public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactory) { - var quota = new CommandGroup("quota", "Quota commands for Azure resource quota checking and usage analysis"); + var quota = new CommandGroup("quota", "Quota commands for getting the available regions of specific Azure resource types" + + " or checking Azure resource quota and usage"); rootGroup.AddSubGroup(quota); var usageGroup = new CommandGroup("usage", "Resource usage and quota operations"); diff --git a/docs/PR-626-Remaining-Work.md b/docs/PR-626-Remaining-Work.md index c086d801c..b52b95d94 100644 --- a/docs/PR-626-Remaining-Work.md +++ b/docs/PR-626-Remaining-Work.md @@ -51,7 +51,7 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - [PostgreSQLUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs) - Justification (if waived): __ -6. [ ] Documentation corrections +6. [x] Documentation corrections - Verify `docs/azmcp-commands.md` has no duplicated tokens (e.g., earlier “quota quota”) & reflects final hierarchical command names consistently (e.g., ensure `deploy infrastructure rules get` vs lingering `iac rules get` examples). - Add brief JSON schema / shape description for `--raw-mcp-tool-input` (architecture diagram + plan) within command docs or link to schema file. - Status: Quota section is already correct (shows `azmcp quota usage check` and `azmcp quota region availability list` once each). Remaining fixes are limited to (a) updating any deploy examples still using `deploy iac rules get` to `deploy infrastructure rules get`, and (b) adding the JSON shape/schema description for `--raw-mcp-tool-input`. @@ -60,7 +60,8 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) - [Plan GetCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs) - Justification (if waived): __ -7. [ ] Source generation coverage review + The command is `deploy iac rules get` instead of `deploy infrastructure rules get` +7. [x] Source generation coverage review - Confirm all JSON-deserialized types used in deploy diagram & plan flows are included in `DeployJsonContext` / `QuotaJsonContext` (nested DTOs, collections). Add any missing types. - Linked Files: - [DeployJsonContext.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/DeployJsonContext.cs) @@ -77,7 +78,7 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - Justification (if waived): __ Run the script. The result show no AOT issue with "deploy/quota" areas. ![alt text](image.png) -9. [-] Test gaps (minimum additions) +9. [x] Test gaps (minimum additions) - Diagram: invalid JSON, empty service list, over-sized payload (return clear message). - Quota: empty / whitespace `resource-types`, mixed casing, unsupported provider => returns “No Limit” entry. - Usage checker: network failure path returns descriptive `UsageInfo.Description`. @@ -86,8 +87,8 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [Quota tests folder](../areas/quota/tests/) - [DiagramGenerateCommandTests.cs] (add if missing under deploy tests) - Justification (if waived): __ - Diagram test is waiting for changes/confirmation -10. [ ] Security & sovereignty + Diagram tool updated with no url output so over-sized payload test not needed. +10. [] Security & sovereignty - Ensure no region / subscription IDs are written to logs at Information or above without user intent. - Confirm no USGov / China cloud breakage due to hard-coded public cloud URLs (see item 2). - Linked Files: @@ -95,7 +96,7 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [PostgreSQLUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs) - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) - Justification (if waived): __ -11. [ ] CHANGELOG / spelling / build gates +11. [x] CHANGELOG / spelling / build gates - Re-run: `./eng/common/spelling/Invoke-Cspell.ps1` and `./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx` post edits; update CHANGELOG if additional user-facing behavior changes (e.g., final command names). - Linked Files / Scripts: - [CHANGELOG.md](../CHANGELOG.md) @@ -103,16 +104,16 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [Build-Local.ps1](../eng/scripts/Build-Local.ps1) - [AzureMcp.sln](../AzureMcp.sln) - Justification (if waived): __ - + need to remove the PR md file to pass. ## P1 (Post‑merge, near term) -1. [ ] Log retrieval abstraction +1. [-] Log retrieval abstraction - Introduce `IAzdAppLogService` (or extend existing interface) wrapping current implementation; mark with `// TODO: Replace with native azd logs command when available`. - Linked Files: - [LogsGetCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs) - [Services folder](../areas/deploy/src/AzureMcp.Deploy/Services/) - Justification (if waived): __ - No azd logs command now. -2. [ ] Extension service reuse + No azd logs command now. will delete this tool when azd log supported. +2. [x] Extension service reuse - Evaluate delegating AZD / AZ related operations via existing extension services (`IAzdService`, `IAzService`) to avoid duplication & ease future azd MCP server integration. - Linked Files: - [Extension AzdCommand.cs](../areas/extension/src/AzureMcp.Extension/Commands/AzdCommand.cs) @@ -120,14 +121,15 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [Deploy Services folder](../areas/deploy/src/AzureMcp.Deploy/Services/) - Justification (if waived): __ No reuse/conflict with existing IAzdService/IAzService. The Deploy service works as a workflow which guide users to use azd/az command. -3. [ ] Improve quota provider extensibility +3. [-] Improve quota provider extensibility - Replace switch/enum mapping with pluggable strategy registration; add test demonstrating adding new provider without core code change. - Linked Files: - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - [Usage provider classes](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/) - Justification (if waived): __ Current switch mode provide enough extensibility and adding new provider will not impact existing provider logic. -4. [ ] Unified cancellation & timeout strategy + No need to use pluggable strategy registration since this method is not shared code. +4. [-] Unified cancellation & timeout strategy - Standardize default timeouts (e.g., 30s) with graceful fallback message; document in command help. - Linked Files: - [Command files (deploy)](../areas/deploy/src/AzureMcp.Deploy/Commands/) diff --git a/docs/azmcp-commands.md b/docs/azmcp-commands.md index 487bd9d56..3f3ab891f 100644 --- a/docs/azmcp-commands.md +++ b/docs/azmcp-commands.md @@ -931,7 +931,7 @@ azmcp deploy pipeline guidance get [--use-azd-pipeline-config ] \ [--github-environment-name ] -# Generate a mermaid architecture diagram for the application topology +# Generate a mermaid architecture diagram for the application topology follow the schema defined in [deploy-app-topology-schema.json](../areas/deploy/src/AzureMcp.Deploy/Schemas/deploy-app-topology-schema.json) azmcp deploy architecture diagram generate --raw-mcp-tool-input ``` From 916181b60cad2a5b5ceab91fa14de6e5dd00e769 Mon Sep 17 00:00:00 2001 From: wchigit <129354560+wchigit@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:22:09 +0800 Subject: [PATCH 47/56] fix parser (#24) --- .../Services/Util/AzdResourceLogService.cs | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs index c074226cc..404c21b49 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs @@ -110,19 +110,26 @@ private static Dictionary ParseAzureYamlServices(YamlDotNet.Cor while (parser.Accept(out _) == false) { var propertyKey = parser.Consume().Value; - var propertyValue = parser.Consume().Value; - - switch (propertyKey) + // Only accept properties host, project, and language which are scalars + if (parser.Accept(out _)) + { + var propertyValue = parser.Consume().Value; + switch (propertyKey) + { + case "host": + host = propertyValue; + break; + case "project": + project = propertyValue; + break; + case "language": + language = propertyValue; + break; + } + } + else { - case "host": - host = propertyValue; - break; - case "project": - project = propertyValue; - break; - case "language": - language = propertyValue; - break; + SkipValue(parser); } } From 945c17bc8b92b2aa6de29ecd440df3f7b851d05b Mon Sep 17 00:00:00 2001 From: qianwens Date: Fri, 15 Aug 2025 19:51:17 +0800 Subject: [PATCH 48/56] update remaining work --- .../Architecture/DiagramGenerateCommand.cs | 10 ++- .../src/AzureMcp.Deploy/Models/Consts.cs | 2 +- .../Architecture/architecture-diagram.md | 6 ++ docs/PR-626-Remaining-Work.md | 62 +++++++++++-------- 4 files changed, 47 insertions(+), 33 deletions(-) create mode 100644 areas/deploy/src/AzureMcp.Deploy/Templates/Architecture/architecture-diagram.md diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs index 512576727..f376b6eb0 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs @@ -8,6 +8,7 @@ using AzureMcp.Core.Helpers; using AzureMcp.Deploy.Options; using AzureMcp.Deploy.Options.Architecture; +using AzureMcp.Deploy.Services.Templates; using Microsoft.Extensions.Logging; namespace AzureMcp.Deploy.Commands.Architecture; @@ -95,12 +96,9 @@ public override Task ExecuteAsync(CommandContext context, Parse ? string.Join(", ", usedServiceTypes) : null; - context.Response.Message = $"Here is the user's mermaid diagram. Write a reminder to the user to install a Mermaid preview extension to be able to render the diagram. " - + $"Please write this into .azure/architecture.copilotmd WITHOUT additional explanations on the deployment. Explain only the architecture and data flow. " - + $"Make changes if these do not fulfill requirements (do not use
in strings when generating the diagram):\n ```mermaid\n{chart}\n``` \n" - + "Ask user if the topology is expected, if not, you should directly update the generated diagram with the user's updated instructions. " - + "Please inform the user that here are the supported hosting technologies: " - + $"{string.Join(", ", Enum.GetNames())}."; + var response = TemplateService.LoadTemplate("Architecture/architecture-diagram"); + context.Response.Message = response.Replace("{{chart}}", chart) + .Replace("{{hostings}}", string.Join(", ", Enum.GetNames())); if (!string.IsNullOrWhiteSpace(usedServiceTypesString)) { context.Response.Message += $"Here is the full list of supported component service types for the topology: {usedServiceTypesString}."; diff --git a/areas/deploy/src/AzureMcp.Deploy/Models/Consts.cs b/areas/deploy/src/AzureMcp.Deploy/Models/Consts.cs index c70953e76..f271ddb8c 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Models/Consts.cs +++ b/areas/deploy/src/AzureMcp.Deploy/Models/Consts.cs @@ -30,7 +30,7 @@ public enum AzureServiceType AzureOpenAI, AzureDatabaseForPostgreSQL, AzurePrivateEndpoint, - AzureRedisCache, + AzureCacheForRedis, AzureSQLDatabase, AzureStorageAccount, StaticWebApp, diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/Architecture/architecture-diagram.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Architecture/architecture-diagram.md new file mode 100644 index 000000000..2d41d3d12 --- /dev/null +++ b/areas/deploy/src/AzureMcp.Deploy/Templates/Architecture/architecture-diagram.md @@ -0,0 +1,6 @@ +Here is the user's mermaid diagram. Write a reminder to the user to install a Mermaid preview extension to be able to render the diagram. +Please write this into .azure/architecture.copilotmd WITHOUT additional explanations on the deployment. Explain only the architecture and data flow. +Make changes if these do not fulfill requirements (do not use
in strings when generating the diagram): + ```mermaid\n{{chart}}\n```. +Ask user if the topology is expected, if not, you should directly update the generated diagram with the user's updated instructions. +Please inform the user that here are the supported hosting technologies: {{hostings}}. \ No newline at end of file diff --git a/docs/PR-626-Remaining-Work.md b/docs/PR-626-Remaining-Work.md index b52b95d94..18a9f696d 100644 --- a/docs/PR-626-Remaining-Work.md +++ b/docs/PR-626-Remaining-Work.md @@ -88,7 +88,7 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [DiagramGenerateCommandTests.cs] (add if missing under deploy tests) - Justification (if waived): __ Diagram tool updated with no url output so over-sized payload test not needed. -10. [] Security & sovereignty +10. [x] Security & sovereignty - Ensure no region / subscription IDs are written to logs at Information or above without user intent. - Confirm no USGov / China cloud breakage due to hard-coded public cloud URLs (see item 2). - Linked Files: @@ -96,6 +96,7 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [PostgreSQLUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs) - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) - Justification (if waived): __ + USGov / China cloud is not supported by this project, need the core lib support the cloud type parameter. 11. [x] CHANGELOG / spelling / build gates - Re-run: `./eng/common/spelling/Invoke-Cspell.ps1` and `./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx` post edits; update CHANGELOG if additional user-facing behavior changes (e.g., final command names). - Linked Files / Scripts: @@ -136,7 +137,7 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [Command files (quota)](../areas/quota/src/AzureMcp.Quota/Commands/) - Justification (if waived): __ It require core framework supports. -5. [ ] Structured output contracts doc +5. [-] Structured output contracts doc - Document JSON contract (property names, nullability) for: usage check, region availability, app logs, plan, diagram (mermaid wrapper), IaC rules. - Linked Files (producers): - [CheckCommand.cs](../areas/quota/src/AzureMcp.Quota/Commands/Usage/CheckCommand.cs) @@ -146,91 +147,100 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) - [RulesGetCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs) - Justification (if waived): __ -6. [ ] Additional tests + There is no structured output contracts doc. +6. [x] Additional tests - JSON round-trip (serialize/deserializing sample payloads) proving source-gen (no reflection fallback). - Large region list & large quota response handling (ensure no OOM or excessive token usage in responses). - Linked Test Locations: - [Quota tests](../areas/quota/tests/) - [Deploy tests](../areas/deploy/tests/) - Justification (if waived): __ -7. [ ] Performance micro-optimizations + The default test already returns large list response. +7. [x] Performance micro-optimizations - Reuse `TokenRequestContext` instances; minimize allocations in diagram generation (StringBuilder pooling if hot path). - Linked Files: - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) - Justification (if waived): __ -8. [ ] Template system consolidation +8. [x] Template system consolidation - Ensure all multi-line textual responses (rules, plan guidance, pipeline guidance) load via `TemplateService`; add unit tests asserting presence/placeholder substitution. - Linked Files / Folders: - [Templates folder (deploy)](../areas/deploy/src/AzureMcp.Deploy/Templates/) - [TemplateService (if present)](../areas/deploy/src/AzureMcp.Deploy/Services/) - Justification (if waived): __ -9. [ ] Logging verbosity flag +9. [x] Logging verbosity flag - Introduce `--verbose` (or reuse global) to elevate detail; keep default output lean. - Linked Files: - [Global options / CLI setup](../core/src/AzureMcp.Core/) - [Deploy command files](../areas/deploy/src/AzureMcp.Deploy/Commands/) - [Quota command files](../areas/quota/src/AzureMcp.Quota/Commands/) - Justification (if waived): __ -10. [ ] Metrics / telemetry hooks (if allowed) +10. [-] Metrics / telemetry hooks (if allowed) - Add (opt-in) counters: command invocation count, duration buckets, failure categories. - Linked Files (potential hooks): - [Core infrastructure](../core/src/AzureMcp.Core/) - [Deploy entry points](../areas/deploy/src/AzureMcp.Deploy/) - [Quota entry points](../areas/quota/src/AzureMcp.Quota/) - Justification (if waived): __ - + Pending for the telemetry support PR in main branch. Will add it in next PR. ## P2 (Deferred / Nice to Have) -1. [ ] Region & quota caching +1. [-] Region & quota caching - Short-lived in-memory cache keyed by (subscription, provider, location) (TTL e.g., 5–10 min) to reduce repeated calls. - Linked Files: - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) - Justification (if waived): __ -2. [ ] Parallelism tuning + Not needed as it is a client side tool. +2. [-] Parallelism tuning - Constrain parallel fan-out (SemaphoreSlim) for very large resource type lists to avoid throttling. - Linked Files: - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) - Justification (if waived): __ -3. [ ] Enhanced diagram generation + Not needed. +3. [-] Enhanced diagram generation - Support optional layers (network / security) via flags while keeping current default simple; enforce size limits. - Linked Files: - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) - [GenerateMermaidChart helper (if present)](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/) - Justification (if waived): __ -4. [ ] CLI help enrichment + May add this feature in the future. +4. [-] CLI help enrichment - Add “See also” sections linking related commands (e.g., plan → rules → pipeline guidance → logs). - Linked Files: - [DeploySetup.cs](../areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs) - [QuotaSetup.cs](../areas/quota/src/AzureMcp.Quota/QuotaSetup.cs) - Justification (if waived): __ -5. [ ] Validation utilities + Not needed. +5. [-] Validation utilities - Centralize resource type & region normalization in shared helper (dedupe logic across quota & deploy areas). - Linked Files (candidates): - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) - [Shared helpers folder (add new)](../core/src/AzureMcp.Core/) - Justification (if waived): __ -6. [ ] Developer area READMEs + Defered. +6. [-] Developer area READMEs - `areas/deploy/README.md` & `areas/quota/README.md` summarizing purpose, extension points, deprecation intent for temporary commands. - Linked Files (to create): - [deploy/README.md](../areas/deploy/README.md) - [quota/README.md](../areas/quota/README.md) - Justification (if waived): __ -7. [ ] Automated smoke tests in CI + Not needed. +7. [-] Automated smoke tests in CI - Lightweight invocations of each new command behind feature flag / mock mode to catch regressions early. - Linked Files / Locations: - [CI pipeline yaml](../eng/pipelines/ci.yml) - [Test harness(es)](../core/tests/) - Justification (if waived): __ -8. [ ] Diagram diffing test harness + Not needed. +8. [-] Diagram diffing test harness - Golden file comparison (with stable ordering) to detect unintended structural changes. - Linked Files / Locations: - [Deploy tests folder](../areas/deploy/tests/) - [Golden files folder (to add)](../areas/deploy/tests/Diagrams/) - Justification (if waived): __ - + Not needed. ## Additional Compliance Items from `docs/new-command.md` Review The following gaps were identified when comparing PR #626 implementation to the command authoring guidance in `docs/new-command.md`. @@ -288,50 +298,50 @@ Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. - [Deploy tests](../areas/deploy/tests/) - [Quota tests](../areas/quota/tests/) - Justification (if waived): __ - 13. [ ] Formatting/style conformance + 13. [x] Formatting/style conformance - Method signatures & parameter wrapping per examples in guidance (`one parameter per line`, aligned indentation). Apply if any deviations exist in new files. - Linked Files: - [Deploy Services](../areas/deploy/src/AzureMcp.Deploy/Services/) - [Quota Services](../areas/quota/src/AzureMcp.Quota/Services/) - Justification (if waived): __ - 14. [ ] Consistent base command inheritance + 14. [x] Consistent base command inheritance - Confirm every command inherits an appropriate `{Area}Command` base (if any direct inheritance from generic base is used, unify design or document exception). - Linked Files: - [Deploy Commands](../areas/deploy/src/AzureMcp.Deploy/Commands/) - [Quota Commands](../areas/quota/src/AzureMcp.Quota/Commands/) - Justification (if waived): __ - 15. [ ] Centralized normalization helpers + 15. [x] Centralized normalization helpers - (If not addressed earlier P2 item) Extract shared parsing / normalization (resource types, region codes) to a shared helper to reduce repetition across commands & services per guideline emphasis on reuse. - Linked Files: - [Quota Services Util](../areas/quota/src/AzureMcp.Quota/Services/Util/) - [Deploy Services](../areas/deploy/src/AzureMcp.Deploy/Services/) - Justification (if waived): __ - 16. [ ] Enhanced troubleshooting messages + 16. [x] Enhanced troubleshooting messages - Ensure error messages include actionable remediation hints (auth, network, throttling) in alignment with guidance examples; add or adjust where currently passive. - Linked Files: - [Deploy Commands](../areas/deploy/src/AzureMcp.Deploy/Commands/) - [Quota Commands](../areas/quota/src/AzureMcp.Quota/Commands/) - Justification (if waived): __ - 17. [ ] Consistent logging context + 17. [-] Consistent logging context - Include key identifiers at Debug/Trace (not Info) per security guidelines; unify field naming (`subscription`, `region`, `resourceType`). - Linked Files: - [Quota Services](../areas/quota/src/AzureMcp.Quota/Services/) - [Deploy Services](../areas/deploy/src/AzureMcp.Deploy/Services/) - Justification (if waived): __ - + Telemetry will be added in another PR. ### P2 (Deferred) - 9. [ ] Live test scenario expansion + 9. [-] Live test scenario expansion - Add multi-region and failure simulation live tests for quota & deploy (e.g., intentionally invalid resource type) once base live infra exists. - Linked Files: - [Quota Live Tests](../areas/quota/tests/) - [Deploy Live Tests](../areas/deploy/tests/) - Justification (if waived): __ - 10. [ ] Test resource cost optimization + 10. [x] Test resource cost optimization - Review any created test resources; ensure minimal SKUs and cleanup practices (align with cost-conscious guidance in new-command doc). - Linked Files: - [Area test-resources.bicep files](../areas/) - Justification (if waived): __ - 11. [ ] Golden output samples for contracts + 11. [-] Golden output samples for contracts - Provide sample JSON outputs (checked into tests) for each command to detect contract drift. - Linked Files: - [Deploy tests](../areas/deploy/tests/) From 768c480b96080f455b00f6e4b025320e81208606 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Fri, 15 Aug 2025 14:26:38 -0700 Subject: [PATCH 49/56] Adds document describing tool organization tasks --- docs/PR-626-Tool-Organization-Tasks.md | 180 +++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 docs/PR-626-Tool-Organization-Tasks.md diff --git a/docs/PR-626-Tool-Organization-Tasks.md b/docs/PR-626-Tool-Organization-Tasks.md new file mode 100644 index 000000000..36bd76dd6 --- /dev/null +++ b/docs/PR-626-Tool-Organization-Tasks.md @@ -0,0 +1,180 @@ + +# MCP/LLM Tool Organization & Task Reference + +This document is the authoritative reference for organizing MCP and LLM tools to support a wide variety of Azure code-to-cloud scenarios. It is designed for easy consumption by agents and LLMs, enabling automation, planning, and code generation workflows. Use this guide to plan, build, and evolve granular, reusable tools for Azure migration, IaC generation, CI/CD pipeline creation, and modernization. All tasks and tools are structured for assertive, agentic execution and continuous improvement. + +## Shared Goal: Granular, Reusable Tools for Azure App Planning & Code Generation + +Work items are broken down into small, focused tools to maximize: +- Reuse across Azure app planning and code generation workflows +- Maintainability and extensibility +- Agentic and CLI-driven automation +- Consistent experience for partners and users + +## Scenarios Supported + +- Brownfield application migration to Azure with azd +- IaC code generation for application components +- Adding new application components to existing applications +- Generating CI/CD pipelines for azd, az cli; supporting both Github Actions and Azure DevOps +- Generating `azd` compatible projects + +All tools and tasks below support these scenarios. Use them to deliver flexible, reusable solutions for Azure app planning and code generation. + +## Priority Legend + +| Priority | Description | +|----------|----------------------------------------------| +| P0 | Required to merge | +| P1 | After initial PR | +| P2 | Nice to have, after P1 issues are resolved | + +## Command Groups + +Use the following command groups for all tools in this PR. + +| Command Group | Description | +|-------------------|---------------------------------------------------| +| **quota** | For all quota and usage tools | +| **infrastructure**| For all generic infrastructure tools | +| **azd** | For all `azd` specific tools | +| **az** | For all `az cli` specific tools | +| **architecture** | For all architecture planning and diagramming tools| +| **pipelines** | For all generic CI/CD pipeline generation tools | +| **github** | For all Github pipeline and integration tools | +| **azuredevops** | For all Azure DevOps pipeline and integration tools| +| **bicep** | For all Bicep IaC generation and validation tools | +| **terraform** | For all Terraform IaC generation and validation tools| + +Deprecate the `deploy` command group. The included tools do not perform application deployment. Focus on migration, architecture planning, and validation to support deployment. + +## [ ] Logs command + +**Goal:** Expose log access via azd CLI for MCP and all azd users. + +**Reason:** Enable standardized log retrieval and automation for MCP and all users. + +### Tasks + +| | Priority | Task | Command Group | +|---|----------|----------------------------------------|--------------| +| [ ] | P2 | Implement `azd monitor logs` command | azd | + +### Links + +| Tool/Task Name | Link | +|:------------------|:--------------------------------------------------------------------------------------------------------| +| LogsGetCommand | [areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs](areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs) | + +## [ ] Diagramming Command + +**Goal:** Redesign as a standalone tool with simple input and Azure resource group support. + +**Reason:** Enable use in any scenario, not just plan-based workflows. + +### Tasks + +| | Priority | Tool | Command Group | +|---|----------|----------------------------------------------|----------------| +| [ ] | P1 | Tool to get topology of application | architecture | +| [ ] | P1 | Tool to generate diagram from topology | architecture | +| [ ] | P1 | Tool to generate diagram from existing Azure resources | architecture | + +### Links + +| Tool/Task Name | Link | +|:------------------------------|:-------------------------------------------------------------------------------------------------------------------------| +| DiagramGenerateCommand | [areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs](areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) | +| GenerateMermaidChart | [areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/GenerateMermaidChart.cs](areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/GenerateMermaidChart.cs) | +| Architecture Diagram Template | [areas/deploy/src/AzureMcp.Deploy/Templates/Architecture/architecture-diagram.md](areas/deploy/src/AzureMcp.Deploy/Templates/Architecture/architecture-diagram.md) | + +## [ ] Pipeline Commands + +**Goal:** Deliver standalone pipeline config, agentic support, and azd/az cli split. + +**Reason:** Enable flexible, best-practice pipeline generation for all platforms. + +### Tasks + +| | Priority | Tool | Command Group | +|---|----------|--------------------------------------------------|----------------| +| [ ] | P1 | Tool for generic pipeline best practices/rules | pipelines | +| [ ] | P1 | Tool for pipeline best practices/rules for Github| github | +| [ ] | P1 | Tool for pipeline best practices/rules for Azure DevOps (azdo) | azuredevops | +| [ ] | P1 | Tool for pipeline best practices/rules for azd | azd | + +### Links + +| Tool/Task Name | Link | +|:---------------------- |:-------------------------------------------------------------------------------------------------------------------------| +| GuidanceGetCommand | [areas/deploy/src/AzureMcp.Deploy/Commands/Pipeline/GuidanceGetCommand.cs](areas/deploy/src/AzureMcp.Deploy/Commands/Pipeline/GuidanceGetCommand.cs) | +| Pipeline Templates | [areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/](areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/) | + +## [ ] Application Migration + +**Goal:** Enable migration and extension of apps to Azure cloud platform. + +**Reason:** Streamline migration, extension, and validation workflows. + +### Tasks + +| | Priority | Tool | Command Group | +|---|----------|--------------------------------------------------|----------------| +| [ ] | P1 | Discover/analyze app components | architecture | +| [ ] | P1 | Generate infrastructure files | infrastructure | +| [ ] | P1 | Containerize apps (dockerfiles) | architecture | +| [ ] | P1 | Generate azure.yaml | azd | +| [ ] | P1 | Generate CI/CD pipelines | pipelines | +| [ ] | P1 | Generate architecture artifacts (plan, diagrams)| architecture | +| [ ] | P1 | Validate app for deployment | azd | +| [ ] | P1 | azure_yaml_schema_validation tool | azd | + +### Links + +| Tool/Task Name | Link | +|:---------------|:--------------------------------------------------------------------------------------------------------| +| GetCommand | [areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs](areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs) | +| Plan Templates | [areas/deploy/src/AzureMcp.Deploy/Templates/Plan/](areas/deploy/src/AzureMcp.Deploy/Templates/Plan/) | + +## [ ] IaC Code Generation + +**Goal:** Standardize and improve IaC code generation and validation. + +**Reason:** Enforce best practices and compatibility across platforms. + +### Tasks + +| | Priority | Tool | Command Group | +|---|----------|--------------------------------------------------|----------------| +| [ ] | P0 | Leverage existing bicep/terraform best practices tools | | +| [ ] | P1 | Tool for generic IaC rules | infrastructure | +| [ ] | P1 | Tool for bicep rules | bicep | +| [ ] | P1 | Tool for terraform rules | terraform | +| [ ] | P1 | Tool for required azd rules | azd | +| [ ] | P2 | Tool for required az cli rules | az | + +### Links + +| Tool/Task Name | Link | +|:-----------------------|:--------------------------------------------------------------------------------------------------------| +| RulesGetCommand | [areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs](areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs) | +| IaC Rules Templates | [areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/](areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/) | + +## [ ] Quota Commands + +**Goal:** Refactor usage checker implementations to a single shared implementation across resource types. + +**Reason:** Eliminate duplication and improve maintainability. + +### Tasks + +| | Priority | Task | Command Group | +|---|----------|-----------------------------------------------------------|--------------| +| [ ] | P2 | Refactor usage checker implementations to a single shared implementation across resource types | quota | + +### Examples + +- [MachineLearningUsageChecker.cs](areas/quota/src/AzureMcp.Quota/Services/Util/Usage/MachineLearningUsageChecker.cs) +- [ComputeUsageChecker.cs](areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ComputeUsageChecker.cs) + +> Note: There are many more very similar examples in the codebase From 902ac6e5b94f82ace9fa77181cb8488d4960a7a8 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Fri, 15 Aug 2025 15:48:59 -0700 Subject: [PATCH 50/56] Updated organization task priorities --- docs/PR-626-Tool-Organization-Tasks.md | 102 +++++++++++++------------ 1 file changed, 52 insertions(+), 50 deletions(-) diff --git a/docs/PR-626-Tool-Organization-Tasks.md b/docs/PR-626-Tool-Organization-Tasks.md index 36bd76dd6..9572573a6 100644 --- a/docs/PR-626-Tool-Organization-Tasks.md +++ b/docs/PR-626-Tool-Organization-Tasks.md @@ -1,15 +1,17 @@ # MCP/LLM Tool Organization & Task Reference -This document is the authoritative reference for organizing MCP and LLM tools to support a wide variety of Azure code-to-cloud scenarios. It is designed for easy consumption by agents and LLMs, enabling automation, planning, and code generation workflows. Use this guide to plan, build, and evolve granular, reusable tools for Azure migration, IaC generation, CI/CD pipeline creation, and modernization. All tasks and tools are structured for assertive, agentic execution and continuous improvement. +This document is the reference for organizing MCP and LLM tools to support a wide variety of Azure code-to-cloud scenarios. It is designed for easy consumption by agents and LLMs, enabling automation, planning, and code generation workflows. Use this guide to plan, build, and evolve granular, reusable tools for Azure migration, IaC generation, CI/CD pipeline creation, and modernization. All tasks and tools are structured for assertive, agentic execution and continuous improvement. ## Shared Goal: Granular, Reusable Tools for Azure App Planning & Code Generation Work items are broken down into small, focused tools to maximize: + - Reuse across Azure app planning and code generation workflows - Maintainability and extensibility - Agentic and CLI-driven automation - Consistent experience for partners and users +- Smaller tools can be used in isolation or as part of orchestrating larger scenarios ## Scenarios Supported @@ -24,27 +26,27 @@ All tools and tasks below support these scenarios. Use them to deliver flexible, ## Priority Legend | Priority | Description | -|----------|----------------------------------------------| +|:--------:|----------------------------------------------| | P0 | Required to merge | | P1 | After initial PR | | P2 | Nice to have, after P1 issues are resolved | ## Command Groups -Use the following command groups for all tools in this PR. - -| Command Group | Description | -|-------------------|---------------------------------------------------| -| **quota** | For all quota and usage tools | -| **infrastructure**| For all generic infrastructure tools | -| **azd** | For all `azd` specific tools | -| **az** | For all `az cli` specific tools | -| **architecture** | For all architecture planning and diagramming tools| -| **pipelines** | For all generic CI/CD pipeline generation tools | -| **github** | For all Github pipeline and integration tools | -| **azuredevops** | For all Azure DevOps pipeline and integration tools| -| **bicep** | For all Bicep IaC generation and validation tools | -| **terraform** | For all Terraform IaC generation and validation tools| +Update to use the command groups for all the tools within this PR. + +| x | Priority | Command Group | Description | +|:---:|:--------:|-------------------|------------------------------------------------------ | +| [ ] | P0 | **quota** | For all quota and usage tools | +| [ ] | P0 | **infrastructure**| For all generic infrastructure tools | +| [ ] | P0 | **azd** | For all `azd` specific tools | +| [ ] | P0 | **az** | For all `az cli` specific tools | +| [ ] | P0 | **architecture** | For all architecture planning and diagramming tools | +| [ ] | P0 | **pipelines** | For all generic CI/CD pipeline generation tools | +| [ ] | P0 | **github** | For all Github pipeline and integration tools | +| [ ] | P0 | **azuredevops** | For all Azure DevOps pipeline and integration tools | +| [ ] | P0 | **bicep** | For all Bicep IaC generation and validation tools | +| [ ] | P0 | **terraform** | For all Terraform IaC generation and validation tools | Deprecate the `deploy` command group. The included tools do not perform application deployment. Focus on migration, architecture planning, and validation to support deployment. @@ -56,9 +58,9 @@ Deprecate the `deploy` command group. The included tools do not perform applicat ### Tasks -| | Priority | Task | Command Group | -|---|----------|----------------------------------------|--------------| -| [ ] | P2 | Implement `azd monitor logs` command | azd | +| x | Priority | Task | Command Group | +|:---:|:--------:|----------------------------------------|---------------| +| [ ] | P2 | Implement `azd monitor logs` command | azd | ### Links @@ -74,11 +76,11 @@ Deprecate the `deploy` command group. The included tools do not perform applicat ### Tasks -| | Priority | Tool | Command Group | -|---|----------|----------------------------------------------|----------------| -| [ ] | P1 | Tool to get topology of application | architecture | -| [ ] | P1 | Tool to generate diagram from topology | architecture | -| [ ] | P1 | Tool to generate diagram from existing Azure resources | architecture | +| x | Priority | Tool | Command Group | +|:---:|:--------:|--------------------------------------------------------|----------------| +| [ ] | P0 | Tool to get topology of application | architecture | +| [ ] | P0 | Tool to generate diagram from topology | architecture | +| [ ] | P1 | Tool to generate diagram from existing Azure resources | architecture | ### Links @@ -96,12 +98,12 @@ Deprecate the `deploy` command group. The included tools do not perform applicat ### Tasks -| | Priority | Tool | Command Group | -|---|----------|--------------------------------------------------|----------------| -| [ ] | P1 | Tool for generic pipeline best practices/rules | pipelines | -| [ ] | P1 | Tool for pipeline best practices/rules for Github| github | -| [ ] | P1 | Tool for pipeline best practices/rules for Azure DevOps (azdo) | azuredevops | -| [ ] | P1 | Tool for pipeline best practices/rules for azd | azd | +| x | Priority | Tool | Command Group | +|:---:|:--------:|----------------------------------------------------------------|----------------| +| [ ] | P0 | Tool for generic pipeline best practices/rules | pipelines | +| [ ] | P0 | Tool for pipeline best practices/rules for Github | github | +| [ ] | P0 | Tool for pipeline best practices/rules for azd | azd | +| [ ] | P1 | Tool for pipeline best practices/rules for Azure DevOps (azdo) | azuredevops | ### Links @@ -118,16 +120,16 @@ Deprecate the `deploy` command group. The included tools do not perform applicat ### Tasks -| | Priority | Tool | Command Group | -|---|----------|--------------------------------------------------|----------------| -| [ ] | P1 | Discover/analyze app components | architecture | -| [ ] | P1 | Generate infrastructure files | infrastructure | -| [ ] | P1 | Containerize apps (dockerfiles) | architecture | -| [ ] | P1 | Generate azure.yaml | azd | -| [ ] | P1 | Generate CI/CD pipelines | pipelines | -| [ ] | P1 | Generate architecture artifacts (plan, diagrams)| architecture | -| [ ] | P1 | Validate app for deployment | azd | -| [ ] | P1 | azure_yaml_schema_validation tool | azd | +| x | Priority | Tool | Command Group | +|:---:|:--------:|--------------------------------------------------|----------------| +| [ ] | P0 | Discover/analyze app components | architecture | +| [ ] | P0 | Generate infrastructure files | infrastructure | +| [ ] | P0 | Containerize apps (dockerfiles) | architecture | +| [ ] | P0 | Generate CI/CD pipelines | pipelines | +| [ ] | P0 | Generate architecture artifacts (plan, diagrams) | architecture | +| [ ] | P0 | Generate azure.yaml | azd | +| [ ] | P0 | Validate app for azd deployment | azd | +| [ ] | P1 | Azure Yaml validation tool | azd | ### Links @@ -144,14 +146,14 @@ Deprecate the `deploy` command group. The included tools do not perform applicat ### Tasks -| | Priority | Tool | Command Group | -|---|----------|--------------------------------------------------|----------------| +| x | Priority | Tool | Command Group | +|:---:|:--------:|--------------------------------------------------------|----------------| | [ ] | P0 | Leverage existing bicep/terraform best practices tools | | -| [ ] | P1 | Tool for generic IaC rules | infrastructure | -| [ ] | P1 | Tool for bicep rules | bicep | -| [ ] | P1 | Tool for terraform rules | terraform | -| [ ] | P1 | Tool for required azd rules | azd | -| [ ] | P2 | Tool for required az cli rules | az | +| [ ] | P0 | Tool for generic IaC best practices /rules | infrastructure | +| [ ] | P0 | Tool for bicep best practices / rules | bicep | +| [ ] | P0 | Tool for terraform best practices / rules | terraform | +| [ ] | P0 | Tool for required azd rules | azd | +| [ ] | P2 | Tool for required az cli rules | az | ### Links @@ -168,9 +170,9 @@ Deprecate the `deploy` command group. The included tools do not perform applicat ### Tasks -| | Priority | Task | Command Group | -|---|----------|-----------------------------------------------------------|--------------| -| [ ] | P2 | Refactor usage checker implementations to a single shared implementation across resource types | quota | +| x | Priority | Task | Command Group | +|:---:|:--------:|------------------------------------------------------------------------------------------------|---------------| +| [ ] | P2 | Refactor usage checker implementations to a single shared implementation across resource types | quota | ### Examples From c622064c390e58acf4d741c3f0ad9df2cca63403 Mon Sep 17 00:00:00 2001 From: Tonychen0227 Date: Mon, 18 Aug 2025 11:31:52 +0800 Subject: [PATCH 51/56] Add 32 character max for resource names (#25) Co-authored-by: Tony Chen (DevDiv) --- .../src/AzureMcp.Deploy/Templates/IaCRules/azcli-rules.md | 2 +- .../src/AzureMcp.Deploy/Templates/IaCRules/bicep-rules.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/azcli-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/azcli-rules.md index 1b77c56d8..f7ff53fc2 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/azcli-rules.md +++ b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/azcli-rules.md @@ -1,4 +1,4 @@ - If creating AzCli script, the script should stop if any command fails. Fix the error before proceeding. -- Azure resource Naming: All resources should be named like {resourcePrefix}{resourceToken}{instance}. Alphanumeric only! Don't use special characters! resourcePrefix is a prefix for the resource (ex. 'kv' for key vault) and <= 3 characters. resourceToken is a random string and = 5 characters. It should be used for all azure resources in a resource group. instance is a number that can be used when there are multiple resource with the same type. For example, resourceToken=abcde, then resource name: myRg(resource group), myKv(keyvault), myServer(sql), myApp1(container app 1), myApp2(container app 2). +- Azure resource Naming: All resources should be named like {resourcePrefix}{resourceToken}{instance}. Alphanumeric only! Don't use special characters! resourcePrefix is a prefix for the resource (ex. 'kv' for key vault) and <= 3 characters. resourceToken is a random string and = 5 characters. It should be used for all azure resources in a resource group. instance is a number that can be used when there are multiple resource with the same type. For example, resourceToken=abcde, then resource name: myRg(resource group), myKv(keyvault), myServer(sql), myApp1(container app 1), myApp2(container app 2). Full resource name must be less than 32 characters. - Kubernetes (K8s) YAML naming: only Lowercase letters (a-z), digits (0-9), hyphens (-) is allowed. Must start and end with a letter or digit. Less than 20 characters. diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/bicep-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/bicep-rules.md index b2b7efb1f..548068175 100644 --- a/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/bicep-rules.md +++ b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/bicep-rules.md @@ -1,3 +1,3 @@ - Expected files: main.bicep, main.parameters.json (with parameters from main.bicep). - Resource token format: 'uniqueString(subscription().id, resourceGroup().id, location, environmentName)' (scope = resourceGroup) or 'uniqueString(subscription().id, location, environmentName)' (scope = subscription). -- All resources should be named like az{resourcePrefix}{resourceToken}, where resourcePrefix is a prefix for the resource (ex. 'kv' for key vault) and <= 3 characters. Alphanumeric only. ResourceToken is the string generated by uniqueString as per earlier. +- All resources should be named like az{resourcePrefix}{resourceToken}, where resourcePrefix is a prefix for the resource (ex. 'kv' for key vault) and <= 3 characters. Alphanumeric only. Entire resource name should be 32 characters maximum. ResourceToken is the string generated by uniqueString as per earlier. \ No newline at end of file From 2f6cb7088820926de3747ed8c072db407eb65512 Mon Sep 17 00:00:00 2001 From: qianwens Date: Mon, 18 Aug 2025 16:00:56 +0800 Subject: [PATCH 52/56] fix build error and comments --- .vscode/cspell.json | 1 + README.md | 13 + docs/PR-626-Action-Plan.md | 152 ---- docs/PR-626-Manual-Testing-Plan.md | 139 ---- docs/PR-626-Remaining-Work.md | 385 --------- docs/PR-626-Tool-Organization-Tasks.md | 182 ----- docs/PRReview.md | 1025 ------------------------ docs/PRReviewNotes.md | 288 ------- docs/azmcp-commands.md | 90 ++- docs/e2eTestPrompts.md | 30 +- 10 files changed, 77 insertions(+), 2228 deletions(-) delete mode 100644 docs/PR-626-Action-Plan.md delete mode 100644 docs/PR-626-Manual-Testing-Plan.md delete mode 100644 docs/PR-626-Remaining-Work.md delete mode 100644 docs/PR-626-Tool-Organization-Tasks.md delete mode 100644 docs/PRReview.md delete mode 100644 docs/PRReviewNotes.md diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 4a7c3a8a9..e327c7f92 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -314,6 +314,7 @@ "GZRS", "healthmodels", "hnsw", + "hostings", "hostpool", "hostpools", "idempotency", diff --git a/README.md b/README.md index 10f14b3a2..68ab08f63 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,14 @@ The Azure MCP Server supercharges your agents with Azure context. Here are some * Support for template discovery, template initialization, provisioning and deployment * Cross-platform compatibility +### 🚀 Azure Deploy + +* Generate azure service architecture diagrams from the source code +* Create a deploy plan for provision and deploy the application +* Get the application service log for a specific azd environment +* Get the bicep or terraform file generation rules for the application +* Get the github pipeline creation guideline for the application + ### 🧮 Azure Foundry * List Azure Foundry models @@ -213,6 +221,11 @@ The Azure MCP Server supercharges your agents with Azure context. Here are some * Scan Azure resources for compliance related recommendations +### 📊 Azure Quota + +* List the available regions +* Check the quota usage + ### 🔴 Azure Redis Cache * List Redis Cluster resources diff --git a/docs/PR-626-Action-Plan.md b/docs/PR-626-Action-Plan.md deleted file mode 100644 index 5bef1666d..000000000 --- a/docs/PR-626-Action-Plan.md +++ /dev/null @@ -1,152 +0,0 @@ -# PR #626 — Deploy and Quota: Action Plan and Next Steps - -This plan consolidates concrete follow-ups to take PR #626 from “draft” to “merge-ready,” based on the repository standards and the findings in PR review notes. - -## Goals and success criteria - -- Consistent, hierarchical command UX reflected across code, tests, and docs -- AOT/trimming and cloud-agnostic compliance preserved (no RequiresDynamicCode; sovereign cloud aware) -- Clean docs (no legacy names), CHANGELOG complete, spelling/build checks clean -- Clear path to reuse az/azd extension services; current temporary logic abstracted - -## P0 (must complete before merge) - -- Naming, descriptions, and docs - - [x] CHANGELOG: switch examples to hierarchical CLI usage (e.g., `azmcp deploy plan get`, `azmcp quota region availability list`), optionally keep MCP tool ids in a separate subsection - - [x] Remove any legacy/hyphenated names from command group descriptions and help - - Files: - - DeploySetup.cs: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs#L26-L31 - - QuotaSetup.cs: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs#L23-L27 - - [x] docs/azmcp-commands.md: ensure examples match current hierarchy; fix duplicated token cases. - - Quota usage example (approx lines): https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/docs/azmcp-commands.md#L897-L905 - - Quota region availability example (approx lines): https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/docs/azmcp-commands.md#L902-L908 - - Deploy section header (approx line): https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/docs/azmcp-commands.md#L909 - - Deploy app logs example (approx lines): https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/docs/azmcp-commands.md#L924-L929 - -- Repo hygiene and gates - - [x] Run spelling: `./eng/common/spelling/Invoke-Cspell.ps1` and fix findings - - [x] Run local verification: `./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx` and fix issues - - [x] Ensure `dotnet build AzureMcp.sln` passes cleanly (warnings-as-errors respected) - -- Robustness and platform compliance - - [x] Add `CancellationToken` plumbing to long-running operations (logs, region/usage queries) and propagate from commands to services - - [x] Replace `Console.WriteLine` usages with `ILogger` (structured, leveled logging) - - [x] Replace static `HttpClient` with `IHttpClientFactory` via DI for direct REST calls - - [x] Ensure sovereign cloud support: avoid hard-coded `https://management.azure.com`; prefer ARM SDK or derive authority from `ArmEnvironment` for PostgreSQL usage checker - - [x] Verify consistent exit codes and clear, actionable error messages - -## P1 (should complete for maintainability) - -- Integration and abstraction - - [x] Introduce an interface (e.g., `IAppLogService`/`IAzdLogService`) and put current logs implementation behind it - **The command will be replaced by azd extension command so no need to refactor to call azd** - - [x] Add a short deprecation note in code/docs indicating intent to delegate to `azd` native logs when available; plan to route via extension service (e.g., `IAzdService`) - -- Tests and schemas - - [x] Diagram command: add tests for malformed/invalid `raw-mcp-tool-input` and oversize payload handling (safe URL limits) - - [x] Quota commands: add tests for empty/whitespace resource types, mixed casing, and very long lists - - [x] Add JSON round-trip tests to prove STJ source-gen coverage (no reflection fallback) - -- Documentation polish - - [x] Per-command help examples (include one example with `raw-mcp-tool-input`) - **Schema is defined in the description of the command** - - [x] Troubleshooting notes (auth, timeouts, diagram URL length) - -## P2 (nice to have) - -- Performance and caching - - [x] Cache region availability results per subscription/provider (short TTL) to reduce redundant queries - **As this is a local tool, caching is not needed** - - [x] Cache embedded templates in `TemplateService` - **As this is a local tool, caching is not needed** - -- UX and contracts - - [x] Optional `--verbose` flag following repo logging conventions - - [x] Document output contracts (shape, casing) and link JSON schemas in docs - -## File-level edits (suggested targets) - -- Descriptions and registration - - `areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs` – group description/help text cleanup; ensure hierarchical verbs in help - - `areas/quota/src/AzureMcp.Quota/QuotaSetup.cs` – confirm concise help and subgroup descriptions - -- Quota services (robustness/cloud) - - `areas/quota/src/AzureMcp.Quota/Services/Util/*.cs` - - Replace `Console.WriteLine` with `ILogger` - - Introduce `CancellationToken` parameters - - Switch static `HttpClient` to `IHttpClientFactory` - - Remove hard-coded management endpoint; prefer ARM SDK or environment-derived authority - - AzureUsageChecker.cs: hardcoded authority and static HttpClient - - Token scope: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs#L68 - - Static HttpClient: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs#L53 - - Console.WriteLine usages: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs#L86-L169 - - PostgreSQLUsageChecker.cs: hardcoded https://management.azure.com - - Request URL: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs#L14-L15 - -- Deploy logs (integration seam) - - `areas/deploy/src/AzureMcp.Deploy/Services/*` – introduce `IAppLogService` (or `IAzdLogService`), adapt current implementation behind interface; prepare to delegate to extension service when available - - AzdResourceLogService.cs - - Entry method: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs#L12-L18 - - AzdAppLogRetriever usage: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs#L24 - - AzdAppLogRetriever.cs - - Type and initializer: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdAppLogRetriever.cs#L11-L31 - - QueryAppLogsAsync switch cases: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdAppLogRetriever.cs#L66-L116 - - TemplateService.cs - - Embedded template loader: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/deploy/src/AzureMcp.Deploy/Services/Templates/TemplateService.cs#L14-L48 - -- Documentation - - `docs/azmcp-commands.md` – hierarchical examples + troubleshooting - - `CHANGELOG.md` – hierarchical CLI usage; optionally list MCP tool ids separately - - JSON source-gen contexts (AOT): - - QuotaJsonContext.cs: https://github.com/qianwens/azure-mcp/blob/qianwen/deploy/areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs#L12-L28 - -## AOT/trimming and cloud checks - -- [x] Run `eng/scripts/Analyze-AOT-Compact.ps1`; resolve any warnings (linker config if needed) -- [x] Ensure only System.Text.Json is used and DTOs are covered by source-gen contexts -- [x] Confirm usage of Azure SDK defaults (retries/timeouts) and respect cloud/authority from environment (sovereign-ready) - -## Validation checklist (green-before-merge) - -- Build: `dotnet build` and `./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx` pass -- Spelling: `./eng/common/spelling/Invoke-Cspell.ps1` clean -- Tests: unit + live tests pass, including new edge cases -- Manual E2E: execute all prompts in `docs/PR-626-Manual-Testing-Plan.md` (Deploy + Quota features from PR #626 only). Record pass/fail notes; all must pass. Create issues for any failures and cross-link to this document and PR #626. -- Help/smoke: `azmcp deploy --help` and `azmcp quota --help` show expected hierarchy; examples are copyable and correct -- Docs: CHANGELOG and azmcp-commands updated - -## Optional runbook (local) - -> The following commands are optional references when validating locally on Windows PowerShell. - -```powershell -# Build + verify -./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx - -# Spelling -./eng/common/spelling/Invoke-Cspell.ps1 - -# Dotnet build -dotnet build ./AzureMcp.sln -``` - -## Ownership and tracking - -- Create or link tracking issues for: - - Logs abstraction + future delegation to `azd` - - Sovereign-cloud compliance in direct REST usage (if any remain after refactor) - - New tests (diagram/edge cases, quota parsing, JSON round-trip) -- Convert the checklists above into PR tasks or repo issues as appropriate. - -### Follow-up Issue Creation (P1/P2) - -For every P1 or P2 item in this action plan that is not completed in PR #626: - -- Create a GitHub issue titled: "[P1|P2] : " -- Labels: `area/deploy` or `area/quota` (and others as appropriate), `priority/P1` or `priority/P2`, and `PR/626-followup` -- Body: problem statement, acceptance criteria, links to exact files/lines and to this document, owner, and due date -- Cross-link in PR #626 and check off the corresponding item here when done - ---- - -Completion definition: All P0 items checked, validation checklist green, and at least the P1 “logs abstraction” in place with a deprecation note for the temporary implementation. diff --git a/docs/PR-626-Manual-Testing-Plan.md b/docs/PR-626-Manual-Testing-Plan.md deleted file mode 100644 index 9a1e7f431..000000000 --- a/docs/PR-626-Manual-Testing-Plan.md +++ /dev/null @@ -1,139 +0,0 @@ -# PR #626 — Manual Testing Plan: 50 Copilot E2E Prompts - -This document lists 50 natural-language prompts you can paste into Copilot to exercise the Deploy and Quota tools introduced by PR #626. Prompts are grouped by capability and phrased to encourage Copilot to invoke the corresponding azmcp commands using the new hierarchical structure. - -Notes -- Scope: Only features introduced in PR #626 (Deploy and Quota tools). Do not use these prompts to validate unrelated/core azmcp functionality. -- These are prompts, not shell commands. Paste into Copilot chat and let it choose the appropriate tool invocation. -- Focus areas: deploy app logs get, deploy infrastructure rules get, deploy pipeline guidance get, deploy plan get, deploy architecture diagram generate, quota usage check, quota region availability list. - -## Quota — Usage Check (Prompts 1–10) -1. Check my Azure quota usage for subscription 11111111-1111-1111-1111-111111111111 in eastus for Microsoft.Compute/virtualMachines. -2. How much VM quota is left in westus2 for my default subscription? -3. Show current usage and limits for Microsoft.ContainerRegistry/registries in eastus2. -4. What are my quotas for App Service plans in centralus? Include used vs. limit. -5. Check GPU VM quota availability in eastus for the Standard_NC series. -6. Give me compute, network, and storage quota usage across eastus and westus. -7. Validate if I can deploy 50 Standard_D4s_v5 VMs in eastus given my current quota. -8. Show quota usage details for Functions in westus (consumption and premium if available). -9. Check current limits for public IP addresses in canadacentral and how many are free. -10. For my staging subscription, list usage for Microsoft.DBforPostgreSQL/flexibleServers in westeurope. - -## Quota — Region Availability (Prompts 11–20) -11. Which regions currently support Microsoft.Compute/virtualMachines? -12. List all regions where Container Apps are available. -13. Where is Azure Database for PostgreSQL Flexible Server available today? -14. What regions support Premium SSD v2 disks for VMs? -15. Show regions that allow Azure OpenAI deployments. -16. For AKS, give me a list of regions with availability and note any regional restrictions. -17. In which regions can I create App Service Linux plans? -18. Where are Cognitive Services available for vision features? -19. Which regions support Availability Zones for VM deployments? -20. List regions that currently offer zone-redundant Container Apps environments. - -## Deploy — Plan Get (Prompts 21–30) -21. Help me plan deployment for my .NET web app in this repo; recommend Azure services and next steps. -22. Create a deployment plan for a Node.js Express API and a React frontend. -23. Generate a plan for a microservices solution with 5 containerized services and a Postgres database. -24. Propose an Azure deployment for a Python FastAPI backend with a static web frontend. -25. I need a cost-optimized plan for a startup MVP with low traffic that can scale later. -26. Draft a HIPAA-aware deployment plan for a healthcare app handling PHI. -27. Plan multi-environment (dev/staging/prod) deployment with environment separation and secrets. -28. Recommend an Azure approach for a background worker/queue processor and a public API. -29. Given no IaC present, suggest the simplest path to ship quickly with azd. -30. Provide a deployment plan tuned for high traffic (100k DAU) with CDN and autoscale. - -## Deploy — Infrastructure Rules Get (Prompts 31–35) -31. Review my Bicep templates and list infrastructure best practices I should adopt. -32. Inspect Terraform in this repo and recommend Azure-specific improvements. -33. Provide baseline IaC rules for network security, tagging, and naming conventions. -34. What are best practices for storing secrets and connection strings in IaC? -35. Suggest IaC rules to prepare for multi-region failover and disaster recovery. - -## Deploy — Pipeline Guidance Get (Prompts 36–40) -36. Generate CI/CD guidance for a GitHub repository that deploys a .NET API to Azure. -37. Recommend an Azure DevOps pipeline for building and deploying a container app. -38. I have a monorepo; what pipeline setup should I use for independent services? -39. Provide guidance for handling secrets, environment variables, and approvals in the pipeline. -40. Help me set up CI/CD that builds PRs, runs tests, and deploys on merges to main. - -## Deploy — App Logs Get (Prompts 41–45) -41. Show application logs for my azd environment named dev from the API service in the last 30 minutes. -42. Fetch logs for the worker service in the staging environment to diagnose timeouts. -43. Get error-level logs only for the web frontend service for the past hour. -44. Retrieve combined logs for all services in the prod environment with timestamps. -45. Find exceptions related to database connectivity across services in the last 24 hours. - -## Deploy — Architecture Diagram Generate (Prompts 46–50) -46. Generate a simple architecture diagram for this application showing web, API, and database. -47. Create an architecture diagram for a 3-service container app with an external database. -48. Produce a deployment diagram highlighting ingress, app services/containers, and storage. -49. Draw a diagram including a queue-based worker, the API, and a public web frontend. -50. Generate a diagram for a microservices layout with internal service-to-service calls and a shared VNet. - -## Test Execution Log (Populate During Manual Run) - -Instructions -- For each prompt, after executing via Copilot, mark Pass (✔) or Fail (✖). Leave the other column blank. -- If a test is blocked (environment/setup), write BLOCKED in Notes with reason. -- If Fail, include the observed command/tool invocation (or absence) and error snippet. -- Use additional rows if you repeat a prompt under different conditions (append .1, .2 to ID). - -| # | Prompt Summary | Pass | Fail | Notes | -|---|----------------|------|------|-------| -| 1 | Quota usage VM eastus specific sub + resource type | | | | -| 2 | VM quota westus2 default subscription | | | | -| 3 | Quota ContainerRegistry eastus2 | | | | -| 4 | App Service plan quotas centralus | | | | -| 5 | GPU VM (Standard_NC) quota eastus | | | | -| 6 | Compute/network/storage quotas eastus+westus | | | | -| 7 | Validate deploy 50 D4s_v5 eastus | | | | -| 8 | Functions quota westus consumption/premium | | | | -| 9 | Public IP quota canadacentral | | | | -| 10 | PostgreSQL flexible servers westeurope | | | | -| 11 | Regions support virtualMachines | | | | -| 12 | Regions Container Apps | | | | -| 13 | Regions PostgreSQL Flexible Server | | | | -| 14 | Regions Premium SSD v2 disks | | | | -| 15 | Regions Azure OpenAI | | | | -| 16 | Regions AKS availability + restrictions | | | | -| 17 | Regions App Service Linux plans | | | | -| 18 | Regions Cognitive Services vision | | | | -| 19 | Regions VM Availability Zones support | | | | -| 20 | Regions zone-redundant Container Apps env | | | | -| 21 | Deployment plan .NET web app | | | | -| 22 | Plan Node.js API + React frontend | | | | -| 23 | Plan microservices 5 containers + Postgres | | | | -| 24 | Plan FastAPI + static web | | | | -| 25 | Cost-optimized MVP scalable | | | | -| 26 | HIPAA-aware plan PHI | | | | -| 27 | Multi-env dev/stage/prod separation | | | | -| 28 | Background worker + queue + public API | | | | -| 29 | No IaC simplest azd path | | | | -| 30 | High traffic 100k DAU with CDN autoscale | | | | -| 31 | Review Bicep infra best practices | | | | -| 32 | Inspect Terraform Azure improvements | | | | -| 33 | Baseline IaC rules security/tagging/naming | | | | -| 34 | Secrets & connection strings in IaC | | | | -| 35 | IaC rules multi-region DR | | | | -| 36 | CI/CD guidance GitHub .NET API | | | | -| 37 | Azure DevOps pipeline container app | | | | -| 38 | Monorepo pipeline independent services | | | | -| 39 | Pipeline secrets/env vars/approvals | | | | -| 40 | CI/CD PR build test deploy on merge | | | | -| 41 | App logs dev env API last 30m | | | | -| 42 | Logs worker staging diagnose timeouts | | | | -| 43 | Error-only logs web frontend 1h | | | | -| 44 | Combined logs all services prod | | | | -| 45 | Exceptions database connectivity 24h | | | | -| 46 | Diagram web + API + database | | | | -| 47 | Diagram 3-service container app + DB | | | | -| 48 | Diagram ingress, app services/containers, storage | | | | -| 49 | Diagram queue worker + API + web | | | | -| 50 | Diagram microservices internal calls + VNet | | | | - -Legend -- Pass: Expected azmcp command invoked; output structurally valid; no unexpected errors. -- Fail: Tool not invoked, wrong command, incorrect output structure, or unhandled error. -- Notes: Include remediation ideas if fail; link to logs or screenshots if applicable. - diff --git a/docs/PR-626-Remaining-Work.md b/docs/PR-626-Remaining-Work.md deleted file mode 100644 index 18a9f696d..000000000 --- a/docs/PR-626-Remaining-Work.md +++ /dev/null @@ -1,385 +0,0 @@ -# PR #626 – Remaining Work Items (Deploy & Quota Areas) - -Status snapshot (2025-08-12): PR introduces Deploy & Quota command areas. Core feature set, tests, and initial docs are present. The items below track what’s still outstanding before (P0) or soon after (P1) merge; P2 are stretch / nice-to-have. - -Legend: P0 = must before merge, P1 = should very soon after, P2 = nice to have. Use label `PR/626-followup` plus `area/deploy` or `area/quota` and `priority/P{n}` when creating issues. - -## P0 (Pre‑merge) -1. [x] Logging & Console output (quota) - - Files: `areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs`, `AzureUsageChecker.cs` - - Replace `Console.WriteLine` with injected `ILogger`; ensure structured messages; remove noisy init logs. - - Linked Files: - - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) - - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - - [PostgreSQLUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs) - - Justification (if waived): __ -2. [-] Hard-coded endpoints & scopes - - `PostgreSQLUsageChecker`: direct `https://management.azure.com/...` URL. - - `AzureUsageChecker.GetQuotaByUrlAsync` uses hard-coded scope `https://management.azure.com/.default` & raw REST. - - Action: Prefer ARM SDK where available; otherwise derive base endpoint from `ArmEnvironment` (sovereign-ready) and pass `CancellationToken`. - - Linked Files: - - [PostgreSQLUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs) - - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - - Justification (if waived): __ - We confirm that the API has no SDK support and we have to call arm endpoint directly. About the code suggestion, can't find any code about `ArmEnvironment`. And we find the `azure-mcp/areas/monitor/src/AzureMcp.Monitor/Services/MonitorHealthModelService.cs` use the same endpoint to access Azure management plan API. -3. [-] CancellationToken plumbing - - Commands & services (region/usage checks, app logs, diagram generation) do not accept / propagate a `CancellationToken`. - - Add CT to public async methods and pass from command execution context. - - Linked Files (examples): - - [Usage CheckCommand](../areas/quota/src/AzureMcp.Quota/Commands/Usage/CheckCommand.cs) - - [Region AvailabilityListCommand](../areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs) - - [LogsGetCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs) - - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) - - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) - - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - - Justification (if waived): __ - Code suggestion about `pass from command execution context`, can't find any CancellationToken in 'command execution context'. It will need to update core/framework to add the CT in 'command execution context', and it's out of the scope of current PR. -4. [x] Error handling consistency - - Avoid `throw new Exception("Error fetching ...: " + error.Message)` which drops stack info; use `throw new InvalidOperationException("...", error)` or rethrow original. - - Standardize user-facing error text (concise + action guidance). - - Linked Files (audit for patterns): - - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - - [PostgreSQLUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs) - - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) - - [LogsGetCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs) - - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) - - Justification (if waived): __ -5. [x] HTTP usage pattern - - `AzureUsageChecker` keeps a static `HttpClient` (OK) but bypasses dependency injection & resiliency policies. - - Action: Introduce `IHttpClientFactory` (named client) + Polly (if repo standard) OR justify keeping static client; wrap responses with meaningful exceptions. - - Linked Files: - - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - - [PostgreSQLUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs) - - Justification (if waived): __ -6. [x] Documentation corrections - - Verify `docs/azmcp-commands.md` has no duplicated tokens (e.g., earlier “quota quota”) & reflects final hierarchical command names consistently (e.g., ensure `deploy infrastructure rules get` vs lingering `iac rules get` examples). - - Add brief JSON schema / shape description for `--raw-mcp-tool-input` (architecture diagram + plan) within command docs or link to schema file. - - Status: Quota section is already correct (shows `azmcp quota usage check` and `azmcp quota region availability list` once each). Remaining fixes are limited to (a) updating any deploy examples still using `deploy iac rules get` to `deploy infrastructure rules get`, and (b) adding the JSON shape/schema description for `--raw-mcp-tool-input`. - - Linked Files: - - [azmcp-commands.md](./azmcp-commands.md) - - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) - - [Plan GetCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs) - - Justification (if waived): __ - The command is `deploy iac rules get` instead of `deploy infrastructure rules get` -7. [x] Source generation coverage review - - Confirm all JSON-deserialized types used in deploy diagram & plan flows are included in `DeployJsonContext` / `QuotaJsonContext` (nested DTOs, collections). Add any missing types. - - Linked Files: - - [DeployJsonContext.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/DeployJsonContext.cs) - - [QuotaJsonContext.cs](../areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs) - - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) - - [GetCommand.cs (Plan)](../areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs) - - Justification (if waived): __ -8. [x] AOT / trimming validation - - Run `./eng/scripts/Analyze-AOT-Compact.ps1`; capture results in PR discussion. Address warnings (linker descriptor if needed for YamlDotNet or reflection on embedded resources). - - Linked Files / Scripts: - - [Analyze-AOT-Compact.ps1](../eng/scripts/Analyze-AOT-Compact.ps1) - - [Deploy csproj](../areas/deploy/src/AzureMcp.Deploy/AzureMcp.Deploy.csproj) - - [Quota csproj](../areas/quota/src/AzureMcp.Quota/AzureMcp.Quota.csproj) - - Justification (if waived): __ - Run the script. The result show no AOT issue with "deploy/quota" areas. - ![alt text](image.png) -9. [x] Test gaps (minimum additions) - - Diagram: invalid JSON, empty service list, over-sized payload (return clear message). - - Quota: empty / whitespace `resource-types`, mixed casing, unsupported provider => returns “No Limit” entry. - - Usage checker: network failure path returns descriptive `UsageInfo.Description`. - - Linked Test Locations (add or extend): - - [Deploy tests folder](../areas/deploy/tests/) - - [Quota tests folder](../areas/quota/tests/) - - [DiagramGenerateCommandTests.cs] (add if missing under deploy tests) - - Justification (if waived): __ - Diagram tool updated with no url output so over-sized payload test not needed. -10. [x] Security & sovereignty - - Ensure no region / subscription IDs are written to logs at Information or above without user intent. - - Confirm no USGov / China cloud breakage due to hard-coded public cloud URLs (see item 2). - - Linked Files: - - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - - [PostgreSQLUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/PostgreSQLUsageChecker.cs) - - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) - - Justification (if waived): __ - USGov / China cloud is not supported by this project, need the core lib support the cloud type parameter. -11. [x] CHANGELOG / spelling / build gates - - Re-run: `./eng/common/spelling/Invoke-Cspell.ps1` and `./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx` post edits; update CHANGELOG if additional user-facing behavior changes (e.g., final command names). - - Linked Files / Scripts: - - [CHANGELOG.md](../CHANGELOG.md) - - [Invoke-Cspell.ps1](../eng/common/spelling/Invoke-Cspell.ps1) - - [Build-Local.ps1](../eng/scripts/Build-Local.ps1) - - [AzureMcp.sln](../AzureMcp.sln) - - Justification (if waived): __ - need to remove the PR md file to pass. -## P1 (Post‑merge, near term) -1. [-] Log retrieval abstraction - - Introduce `IAzdAppLogService` (or extend existing interface) wrapping current implementation; mark with `// TODO: Replace with native azd logs command when available`. - - Linked Files: - - [LogsGetCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs) - - [Services folder](../areas/deploy/src/AzureMcp.Deploy/Services/) - - Justification (if waived): __ - No azd logs command now. will delete this tool when azd log supported. -2. [x] Extension service reuse - - Evaluate delegating AZD / AZ related operations via existing extension services (`IAzdService`, `IAzService`) to avoid duplication & ease future azd MCP server integration. - - Linked Files: - - [Extension AzdCommand.cs](../areas/extension/src/AzureMcp.Extension/Commands/AzdCommand.cs) - - [Extension AzCommand.cs](../areas/extension/src/AzureMcp.Extension/Commands/AzCommand.cs) - - [Deploy Services folder](../areas/deploy/src/AzureMcp.Deploy/Services/) - - Justification (if waived): __ - No reuse/conflict with existing IAzdService/IAzService. The Deploy service works as a workflow which guide users to use azd/az command. -3. [-] Improve quota provider extensibility - - Replace switch/enum mapping with pluggable strategy registration; add test demonstrating adding new provider without core code change. - - Linked Files: - - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - - [Usage provider classes](../areas/quota/src/AzureMcp.Quota/Services/Util/Usage/) - - Justification (if waived): __ - Current switch mode provide enough extensibility and adding new provider will not impact existing provider logic. - No need to use pluggable strategy registration since this method is not shared code. -4. [-] Unified cancellation & timeout strategy - - Standardize default timeouts (e.g., 30s) with graceful fallback message; document in command help. - - Linked Files: - - [Command files (deploy)](../areas/deploy/src/AzureMcp.Deploy/Commands/) - - [Command files (quota)](../areas/quota/src/AzureMcp.Quota/Commands/) - - Justification (if waived): __ - It require core framework supports. -5. [-] Structured output contracts doc - - Document JSON contract (property names, nullability) for: usage check, region availability, app logs, plan, diagram (mermaid wrapper), IaC rules. - - Linked Files (producers): - - [CheckCommand.cs](../areas/quota/src/AzureMcp.Quota/Commands/Usage/CheckCommand.cs) - - [AvailabilityListCommand.cs](../areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs) - - [LogsGetCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs) - - [GetCommand.cs (Plan)](../areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs) - - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) - - [RulesGetCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs) - - Justification (if waived): __ - There is no structured output contracts doc. -6. [x] Additional tests - - JSON round-trip (serialize/deserializing sample payloads) proving source-gen (no reflection fallback). - - Large region list & large quota response handling (ensure no OOM or excessive token usage in responses). - - Linked Test Locations: - - [Quota tests](../areas/quota/tests/) - - [Deploy tests](../areas/deploy/tests/) - - Justification (if waived): __ - The default test already returns large list response. -7. [x] Performance micro-optimizations - - Reuse `TokenRequestContext` instances; minimize allocations in diagram generation (StringBuilder pooling if hot path). - - Linked Files: - - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) - - Justification (if waived): __ -8. [x] Template system consolidation - - Ensure all multi-line textual responses (rules, plan guidance, pipeline guidance) load via `TemplateService`; add unit tests asserting presence/placeholder substitution. - - Linked Files / Folders: - - [Templates folder (deploy)](../areas/deploy/src/AzureMcp.Deploy/Templates/) - - [TemplateService (if present)](../areas/deploy/src/AzureMcp.Deploy/Services/) - - Justification (if waived): __ -9. [x] Logging verbosity flag - - Introduce `--verbose` (or reuse global) to elevate detail; keep default output lean. - - Linked Files: - - [Global options / CLI setup](../core/src/AzureMcp.Core/) - - [Deploy command files](../areas/deploy/src/AzureMcp.Deploy/Commands/) - - [Quota command files](../areas/quota/src/AzureMcp.Quota/Commands/) - - Justification (if waived): __ -10. [-] Metrics / telemetry hooks (if allowed) - - Add (opt-in) counters: command invocation count, duration buckets, failure categories. - - Linked Files (potential hooks): - - [Core infrastructure](../core/src/AzureMcp.Core/) - - [Deploy entry points](../areas/deploy/src/AzureMcp.Deploy/) - - [Quota entry points](../areas/quota/src/AzureMcp.Quota/) - - Justification (if waived): __ - Pending for the telemetry support PR in main branch. Will add it in next PR. -## P2 (Deferred / Nice to Have) -1. [-] Region & quota caching - - Short-lived in-memory cache keyed by (subscription, provider, location) (TTL e.g., 5–10 min) to reduce repeated calls. - - Linked Files: - - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) - - Justification (if waived): __ - Not needed as it is a client side tool. -2. [-] Parallelism tuning - - Constrain parallel fan-out (SemaphoreSlim) for very large resource type lists to avoid throttling. - - Linked Files: - - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) - - Justification (if waived): __ - Not needed. -3. [-] Enhanced diagram generation - - Support optional layers (network / security) via flags while keeping current default simple; enforce size limits. - - Linked Files: - - [DiagramGenerateCommand.cs](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) - - [GenerateMermaidChart helper (if present)](../areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/) - - Justification (if waived): __ - May add this feature in the future. -4. [-] CLI help enrichment - - Add “See also” sections linking related commands (e.g., plan → rules → pipeline guidance → logs). - - Linked Files: - - [DeploySetup.cs](../areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs) - - [QuotaSetup.cs](../areas/quota/src/AzureMcp.Quota/QuotaSetup.cs) - - Justification (if waived): __ - Not needed. -5. [-] Validation utilities - - Centralize resource type & region normalization in shared helper (dedupe logic across quota & deploy areas). - - Linked Files (candidates): - - [AzureUsageChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs) - - [AzureRegionChecker.cs](../areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs) - - [Shared helpers folder (add new)](../core/src/AzureMcp.Core/) - - Justification (if waived): __ - Defered. -6. [-] Developer area READMEs - - `areas/deploy/README.md` & `areas/quota/README.md` summarizing purpose, extension points, deprecation intent for temporary commands. - - Linked Files (to create): - - [deploy/README.md](../areas/deploy/README.md) - - [quota/README.md](../areas/quota/README.md) - - Justification (if waived): __ - Not needed. -7. [-] Automated smoke tests in CI - - Lightweight invocations of each new command behind feature flag / mock mode to catch regressions early. - - Linked Files / Locations: - - [CI pipeline yaml](../eng/pipelines/ci.yml) - - [Test harness(es)](../core/tests/) - - Justification (if waived): __ - Not needed. -8. [-] Diagram diffing test harness - - Golden file comparison (with stable ordering) to detect unintended structural changes. - - Linked Files / Locations: - - [Deploy tests folder](../areas/deploy/tests/) - - [Golden files folder (to add)](../areas/deploy/tests/Diagrams/) - - Justification (if waived): __ - Not needed. - ## Additional Compliance Items from `docs/new-command.md` Review - - The following gaps were identified when comparing PR #626 implementation to the command authoring guidance in `docs/new-command.md`. - - ### P0 (Pre‑merge) - 12. [-] Command naming pattern audit - - Ensure every command class follows `{Resource}{SubResource?}{Operation}Command` (e.g., `PlanGetCommand`, `InfrastructureRulesGetCommand`, `PipelineGuidanceGetCommand`, `ArchitectureDiagramGenerateCommand`, `AppLogsGetCommand`, `UsageCheckCommand`, `RegionAvailabilityListCommand`). - - If current classes use shortened forms (e.g., `GetCommand`, `RulesGetCommand`, `GuidanceGetCommand`, `DiagramGenerateCommand`, `LogsGetCommand`) without the primary resource prefix, evaluate renaming for consistency OR document an explicit exception rationale. - - Linked Files: - - [Deploy Commands](../areas/deploy/src/AzureMcp.Deploy/Commands/) - - [Quota Commands](../areas/quota/src/AzureMcp.Quota/Commands/) - - Justification (if waived): __ - Class name follow the suggestion in https://github.com/qianwens/azure-mcp/commit/0215c924be0471fa2d6d08aee74e5f890c75c8ef. - 13. [x] Service interface coverage - - Each logical capability should have a service interface + implementation rather than embedding logic directly in command classes (plan, rules, pipeline guidance, diagram generation, app logs, quota usage, region availability). Verify a corresponding `I*Service` exists; add missing ones. - - Linked Files: - - [Deploy Services](../areas/deploy/src/AzureMcp.Deploy/Services/) - - [Quota Services](../areas/quota/src/AzureMcp.Quota/Services/) - - Justification (if waived): __ - 14. [x] OptionDefinitions reuse & duplication check - - Confirm no redefinition of global/area options in per-command options; ensure only incremental properties added. Validate subscription parameter consistently named `subscription` (never `subscriptionId`). - - Linked Files: - - [Deploy Options](../areas/deploy/src/AzureMcp.Deploy/Options/) - - [Quota Options](../areas/quota/src/AzureMcp.Quota/Options/) - - Justification (if waived): __ - 15. [x] Area registration ordering - - Verify new areas (Deploy, Quota) appear in alphabetical order in `Program.cs` area registration array. - - Linked Files: - - [Program.cs](../core/src/AzureMcp.Cli/Program.cs) - - Justification (if waived): __ - Sort the registration array. But it cause existing files also be sorted. - 16. [x] Unit test completeness per command - - Ensure every command has a corresponding `*CommandTests` class (naming aligns with command naming pattern) covering validation & success paths. - - Linked Files: - - [Deploy Unit Tests](../areas/deploy/tests/) - - [Quota Unit Tests](../areas/quota/tests/) - - Justification (if waived): __ - 17. [x] Error handling override usage - - For commands with domain-specific errors, override `GetErrorMessage` / `GetStatusCode` per guidance rather than relying solely on base behavior; add missing overrides where user-actionable mapping adds value. - - Linked Files: - - [Deploy Commands](../areas/deploy/src/AzureMcp.Deploy/Commands/) - - [Quota Commands](../areas/quota/src/AzureMcp.Quota/Commands/) - - Justification (if waived): __ - - ### P1 (Post‑merge) - 11. [x] Live test infrastructure - - Add / validate `test-resources.bicep` & optional `test-resources-post.ps1` for Deploy & Quota areas if live (integration) tests depend on Azure resources (diagram/plan may not; quota usage & region availability likely do). If intentionally omitted, document rationale. - - Linked Files: - - [Quota tests root](../areas/quota/tests/) - - [Deploy tests root](../areas/deploy/tests/) - - Justification (if waived): __ - 12. [x] Naming consistency in test classes - - Ensure test class names mirror final command class names exactly (e.g., `PlanGetCommandTests`). Rename where mismatched. - - Linked Files: - - [Deploy tests](../areas/deploy/tests/) - - [Quota tests](../areas/quota/tests/) - - Justification (if waived): __ - 13. [x] Formatting/style conformance - - Method signatures & parameter wrapping per examples in guidance (`one parameter per line`, aligned indentation). Apply if any deviations exist in new files. - - Linked Files: - - [Deploy Services](../areas/deploy/src/AzureMcp.Deploy/Services/) - - [Quota Services](../areas/quota/src/AzureMcp.Quota/Services/) - - Justification (if waived): __ - 14. [x] Consistent base command inheritance - - Confirm every command inherits an appropriate `{Area}Command` base (if any direct inheritance from generic base is used, unify design or document exception). - - Linked Files: - - [Deploy Commands](../areas/deploy/src/AzureMcp.Deploy/Commands/) - - [Quota Commands](../areas/quota/src/AzureMcp.Quota/Commands/) - - Justification (if waived): __ - 15. [x] Centralized normalization helpers - - (If not addressed earlier P2 item) Extract shared parsing / normalization (resource types, region codes) to a shared helper to reduce repetition across commands & services per guideline emphasis on reuse. - - Linked Files: - - [Quota Services Util](../areas/quota/src/AzureMcp.Quota/Services/Util/) - - [Deploy Services](../areas/deploy/src/AzureMcp.Deploy/Services/) - - Justification (if waived): __ - 16. [x] Enhanced troubleshooting messages - - Ensure error messages include actionable remediation hints (auth, network, throttling) in alignment with guidance examples; add or adjust where currently passive. - - Linked Files: - - [Deploy Commands](../areas/deploy/src/AzureMcp.Deploy/Commands/) - - [Quota Commands](../areas/quota/src/AzureMcp.Quota/Commands/) - - Justification (if waived): __ - 17. [-] Consistent logging context - - Include key identifiers at Debug/Trace (not Info) per security guidelines; unify field naming (`subscription`, `region`, `resourceType`). - - Linked Files: - - [Quota Services](../areas/quota/src/AzureMcp.Quota/Services/) - - [Deploy Services](../areas/deploy/src/AzureMcp.Deploy/Services/) - - Justification (if waived): __ - Telemetry will be added in another PR. - ### P2 (Deferred) - 9. [-] Live test scenario expansion - - Add multi-region and failure simulation live tests for quota & deploy (e.g., intentionally invalid resource type) once base live infra exists. - - Linked Files: - - [Quota Live Tests](../areas/quota/tests/) - - [Deploy Live Tests](../areas/deploy/tests/) - - Justification (if waived): __ - 10. [x] Test resource cost optimization - - Review any created test resources; ensure minimal SKUs and cleanup practices (align with cost-conscious guidance in new-command doc). - - Linked Files: - - [Area test-resources.bicep files](../areas/) - - Justification (if waived): __ - 11. [-] Golden output samples for contracts - - Provide sample JSON outputs (checked into tests) for each command to detect contract drift. - - Linked Files: - - [Deploy tests](../areas/deploy/tests/) - - [Quota tests](../areas/quota/tests/) - - Justification (if waived): __ - - NOTE: If any items above are already satisfied but not yet documented, append a short "Rationale/Completion" note under the item rather than removing it to preserve auditability. - -## Issue Creation Template (copy/paste) -``` -Title: [P0|P1|P2] : -Labels: area/, priority/P#, PR/626-followup - -Problem - - -Acceptance Criteria -- [ ] ... -- [ ] ... - -References -:L (if applicable) -PR #626 -docs/PR-626-Remaining-Work.md - -Owner: -Due: -``` - -## Quick Verification Commands (reference) -``` -./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx -./eng/common/spelling/Invoke-Cspell.ps1 -dotnet build AzureMcp.sln -``` - -## Completion Definition -All P0 items checked off (or explicitly waived with rationale in PR discussion) + build/test/spelling/AOT analyses green. P1 items converted to issues with owners & due dates. - ---- -Maintainer note: Update this document (appending a “Changelog” section) rather than editing historical entries when marking items done; keep an auditable trail. diff --git a/docs/PR-626-Tool-Organization-Tasks.md b/docs/PR-626-Tool-Organization-Tasks.md deleted file mode 100644 index 9572573a6..000000000 --- a/docs/PR-626-Tool-Organization-Tasks.md +++ /dev/null @@ -1,182 +0,0 @@ - -# MCP/LLM Tool Organization & Task Reference - -This document is the reference for organizing MCP and LLM tools to support a wide variety of Azure code-to-cloud scenarios. It is designed for easy consumption by agents and LLMs, enabling automation, planning, and code generation workflows. Use this guide to plan, build, and evolve granular, reusable tools for Azure migration, IaC generation, CI/CD pipeline creation, and modernization. All tasks and tools are structured for assertive, agentic execution and continuous improvement. - -## Shared Goal: Granular, Reusable Tools for Azure App Planning & Code Generation - -Work items are broken down into small, focused tools to maximize: - -- Reuse across Azure app planning and code generation workflows -- Maintainability and extensibility -- Agentic and CLI-driven automation -- Consistent experience for partners and users -- Smaller tools can be used in isolation or as part of orchestrating larger scenarios - -## Scenarios Supported - -- Brownfield application migration to Azure with azd -- IaC code generation for application components -- Adding new application components to existing applications -- Generating CI/CD pipelines for azd, az cli; supporting both Github Actions and Azure DevOps -- Generating `azd` compatible projects - -All tools and tasks below support these scenarios. Use them to deliver flexible, reusable solutions for Azure app planning and code generation. - -## Priority Legend - -| Priority | Description | -|:--------:|----------------------------------------------| -| P0 | Required to merge | -| P1 | After initial PR | -| P2 | Nice to have, after P1 issues are resolved | - -## Command Groups - -Update to use the command groups for all the tools within this PR. - -| x | Priority | Command Group | Description | -|:---:|:--------:|-------------------|------------------------------------------------------ | -| [ ] | P0 | **quota** | For all quota and usage tools | -| [ ] | P0 | **infrastructure**| For all generic infrastructure tools | -| [ ] | P0 | **azd** | For all `azd` specific tools | -| [ ] | P0 | **az** | For all `az cli` specific tools | -| [ ] | P0 | **architecture** | For all architecture planning and diagramming tools | -| [ ] | P0 | **pipelines** | For all generic CI/CD pipeline generation tools | -| [ ] | P0 | **github** | For all Github pipeline and integration tools | -| [ ] | P0 | **azuredevops** | For all Azure DevOps pipeline and integration tools | -| [ ] | P0 | **bicep** | For all Bicep IaC generation and validation tools | -| [ ] | P0 | **terraform** | For all Terraform IaC generation and validation tools | - -Deprecate the `deploy` command group. The included tools do not perform application deployment. Focus on migration, architecture planning, and validation to support deployment. - -## [ ] Logs command - -**Goal:** Expose log access via azd CLI for MCP and all azd users. - -**Reason:** Enable standardized log retrieval and automation for MCP and all users. - -### Tasks - -| x | Priority | Task | Command Group | -|:---:|:--------:|----------------------------------------|---------------| -| [ ] | P2 | Implement `azd monitor logs` command | azd | - -### Links - -| Tool/Task Name | Link | -|:------------------|:--------------------------------------------------------------------------------------------------------| -| LogsGetCommand | [areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs](areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs) | - -## [ ] Diagramming Command - -**Goal:** Redesign as a standalone tool with simple input and Azure resource group support. - -**Reason:** Enable use in any scenario, not just plan-based workflows. - -### Tasks - -| x | Priority | Tool | Command Group | -|:---:|:--------:|--------------------------------------------------------|----------------| -| [ ] | P0 | Tool to get topology of application | architecture | -| [ ] | P0 | Tool to generate diagram from topology | architecture | -| [ ] | P1 | Tool to generate diagram from existing Azure resources | architecture | - -### Links - -| Tool/Task Name | Link | -|:------------------------------|:-------------------------------------------------------------------------------------------------------------------------| -| DiagramGenerateCommand | [areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs](areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs) | -| GenerateMermaidChart | [areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/GenerateMermaidChart.cs](areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/GenerateMermaidChart.cs) | -| Architecture Diagram Template | [areas/deploy/src/AzureMcp.Deploy/Templates/Architecture/architecture-diagram.md](areas/deploy/src/AzureMcp.Deploy/Templates/Architecture/architecture-diagram.md) | - -## [ ] Pipeline Commands - -**Goal:** Deliver standalone pipeline config, agentic support, and azd/az cli split. - -**Reason:** Enable flexible, best-practice pipeline generation for all platforms. - -### Tasks - -| x | Priority | Tool | Command Group | -|:---:|:--------:|----------------------------------------------------------------|----------------| -| [ ] | P0 | Tool for generic pipeline best practices/rules | pipelines | -| [ ] | P0 | Tool for pipeline best practices/rules for Github | github | -| [ ] | P0 | Tool for pipeline best practices/rules for azd | azd | -| [ ] | P1 | Tool for pipeline best practices/rules for Azure DevOps (azdo) | azuredevops | - -### Links - -| Tool/Task Name | Link | -|:---------------------- |:-------------------------------------------------------------------------------------------------------------------------| -| GuidanceGetCommand | [areas/deploy/src/AzureMcp.Deploy/Commands/Pipeline/GuidanceGetCommand.cs](areas/deploy/src/AzureMcp.Deploy/Commands/Pipeline/GuidanceGetCommand.cs) | -| Pipeline Templates | [areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/](areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/) | - -## [ ] Application Migration - -**Goal:** Enable migration and extension of apps to Azure cloud platform. - -**Reason:** Streamline migration, extension, and validation workflows. - -### Tasks - -| x | Priority | Tool | Command Group | -|:---:|:--------:|--------------------------------------------------|----------------| -| [ ] | P0 | Discover/analyze app components | architecture | -| [ ] | P0 | Generate infrastructure files | infrastructure | -| [ ] | P0 | Containerize apps (dockerfiles) | architecture | -| [ ] | P0 | Generate CI/CD pipelines | pipelines | -| [ ] | P0 | Generate architecture artifacts (plan, diagrams) | architecture | -| [ ] | P0 | Generate azure.yaml | azd | -| [ ] | P0 | Validate app for azd deployment | azd | -| [ ] | P1 | Azure Yaml validation tool | azd | - -### Links - -| Tool/Task Name | Link | -|:---------------|:--------------------------------------------------------------------------------------------------------| -| GetCommand | [areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs](areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs) | -| Plan Templates | [areas/deploy/src/AzureMcp.Deploy/Templates/Plan/](areas/deploy/src/AzureMcp.Deploy/Templates/Plan/) | - -## [ ] IaC Code Generation - -**Goal:** Standardize and improve IaC code generation and validation. - -**Reason:** Enforce best practices and compatibility across platforms. - -### Tasks - -| x | Priority | Tool | Command Group | -|:---:|:--------:|--------------------------------------------------------|----------------| -| [ ] | P0 | Leverage existing bicep/terraform best practices tools | | -| [ ] | P0 | Tool for generic IaC best practices /rules | infrastructure | -| [ ] | P0 | Tool for bicep best practices / rules | bicep | -| [ ] | P0 | Tool for terraform best practices / rules | terraform | -| [ ] | P0 | Tool for required azd rules | azd | -| [ ] | P2 | Tool for required az cli rules | az | - -### Links - -| Tool/Task Name | Link | -|:-----------------------|:--------------------------------------------------------------------------------------------------------| -| RulesGetCommand | [areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs](areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs) | -| IaC Rules Templates | [areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/](areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/) | - -## [ ] Quota Commands - -**Goal:** Refactor usage checker implementations to a single shared implementation across resource types. - -**Reason:** Eliminate duplication and improve maintainability. - -### Tasks - -| x | Priority | Task | Command Group | -|:---:|:--------:|------------------------------------------------------------------------------------------------|---------------| -| [ ] | P2 | Refactor usage checker implementations to a single shared implementation across resource types | quota | - -### Examples - -- [MachineLearningUsageChecker.cs](areas/quota/src/AzureMcp.Quota/Services/Util/Usage/MachineLearningUsageChecker.cs) -- [ComputeUsageChecker.cs](areas/quota/src/AzureMcp.Quota/Services/Util/Usage/ComputeUsageChecker.cs) - -> Note: There are many more very similar examples in the codebase diff --git a/docs/PRReview.md b/docs/PRReview.md deleted file mode 100644 index 66196ee05..000000000 --- a/docs/PRReview.md +++ /dev/null @@ -1,1025 +0,0 @@ -# PR #626 Final Recommendations - Deploy and Quota Commands (Test inprogress) - -## Executive Summary - -This document consolidates all analysis, feedback, and recommendations for PR #626 which introduces deployment and quota management commands to Azure MCP Server. Based on architectural review, standards compliance analysis, and stakeholder feedback, this document provides the definitive refactoring plan. - -## Table of Contents - -1. [Current State Analysis](#current-state-analysis) -2. [Final Architecture Recommendations](#final-architecture-recommendations) -3. [Command Structure Reorganization](#command-structure-reorganization) -4. [Integration with Existing Commands](#integration-with-existing-commands) -5. [Implementation Action Plan](#implementation-action-plan) -6. [Test Scenarios](#test-scenarios) -7. [Validation Criteria](#validation-criteria) - -> Tracking: For any P1 or P2 item not completed in this PR, file a follow-up GitHub issue (see "Follow-up Issue Creation" below) so we do not lose scope after merge. - -## Current State Analysis - -### PR Overview -- **Files Added**: 105 files with 6,521 additions and 44 deletions -- **New Areas**: `deploy` and `quota` command areas -- **Current Commands**: 7 commands with hyphenated naming and flat registration - -### Standards Violations Identified -1. **Command Registration**: Uses flat `AddCommand()` instead of hierarchical `CommandGroup` pattern -2. **Command Naming**: Hyphenated names (`plan-get`, `iac-rules-get`) violate ` ` pattern -3. **Architecture**: Overlaps with existing `AzCommand` and `AzdCommand` in `areas/extension/` - -### Existing Extension Commands -The codebase contains: -- `areas/extension/src/AzureMcp.Extension/Commands/AzCommand.cs` - Full Azure CLI execution -- `areas/extension/src/AzureMcp.Extension/Commands/AzdCommand.cs` - Full AZD execution - -## Final Architecture Recommendations - -### Command Groups - -Organize tools into these command groups: - -1. **`quota`** - Resource quota checking and usage analysis -2. **`deploy azd`** - AZD-specific deployment tools -3. **`deploy az`** - Azure CLI-specific deployment tools -4. **`deploy diagrams`** - Architecture diagram generation - -### Command Structure Changes - -**From Current**: -```bash -azmcp deploy plan-get -azmcp deploy iac-rules-get -azmcp deploy azd-app-log-get -azmcp deploy cicd-pipeline-guidance-get -azmcp deploy architecture-diagram-generate -azmcp quota usage-get -azmcp quota available-region-list -``` - -**To Target**: -```bash -azmcp quota usage check -azmcp quota region availability list -azmcp deploy app logs get -azmcp deploy infrastructure rules get -azmcp deploy pipeline guidance get -azmcp deploy plan get -azmcp deploy architecture diagram generate -``` - -### Integration Strategy - -Integration with existing commands: -- **AZD Operations**: Use existing `azmcp extension azd` internally -- **Azure CLI Operations**: Use existing `azmcp extension az` internally -- **Value-Added Services**: PR commands provide structured guidance on top of base CLI - -## Command Structure Reorganization - -### Deploy Area Refactoring - -**File**: `areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs` - -**Target Structure**: -```csharp -public static void RegisterCommands(CommandGroup deploy) -{ - // Application-specific commands - var appGroup = new CommandGroup("app", "Application-specific deployment tools"); - appGroup.AddCommand("logs", new LogsGetCommand(...)); // app logs get - - // Infrastructure as Code commands - var infrastructureGroup = new CommandGroup("infrastructure", "Infrastructure as Code operations"); - infrastructureGroup.AddCommand("rules", new RulesGetCommand(...)); // infrastructure rules get - - // CI/CD Pipeline commands - var pipelineGroup = new CommandGroup("pipeline", "CI/CD pipeline operations"); - pipelineGroup.AddCommand("guidance", new GuidanceGetCommand(...)); // pipeline guidance get - - // Deployment planning commands - var planGroup = new CommandGroup("plan", "Deployment planning operations"); - planGroup.AddCommand("get", new GetCommand(...)); // plan get - - // Architecture diagram commands - var architectureGroup = new CommandGroup("architecture", "Architecture diagram operations"); - architectureGroup.AddCommand("diagram", new DiagramGenerateCommand(...)); // architecture diagram generate - - deploy.AddCommandGroup(appGroup); - deploy.AddCommandGroup(infrastructureGroup); - deploy.AddCommandGroup(pipelineGroup); - deploy.AddCommandGroup(planGroup); - deploy.AddCommandGroup(architectureGroup); -} -``` - -### Quota Area Refactoring - -**File**: `areas/quota/src/AzureMcp.Quota/QuotaSetup.cs` - -**Target Structure**: -```csharp -public static void RegisterCommands(CommandGroup quota) -{ - // Resource usage and quota operations - var usageGroup = new CommandGroup("usage", "Resource usage and quota operations"); - usageGroup.AddCommand("check", new CheckCommand(...)); // usage check - - // Region availability operations - var regionGroup = new CommandGroup("region", "Region availability operations"); - regionGroup.AddCommand("availability", new AvailabilityListCommand(...)); // region availability list - - quota.AddCommandGroup(usageGroup); - quota.AddCommandGroup(regionGroup); -} -``` - -### Command Name Property Updates - -**Changes Required**: -1. `PlanGetCommand.Name` → `"get"` (was `"plan-get"`) -2. `IaCRulesGetCommand.Name` → `"rules"` (was `"iac-rules-get"`) -3. `AzdAppLogGetCommand.Name` → `"logs"` (was `"azd-app-log-get"`) -4. `PipelineGenerateCommand.Name` → `"guidance"` (was `"cicd-pipeline-guidance-get"`) -5. `GenerateArchitectureDiagramCommand.Name` → `"diagram"` (was `"architecture-diagram-generate"`) -6. `UsageCheckCommand.Name` → `"check"` (was `"usage-get"`) -7. `RegionCheckCommand.Name` → `"availability"` (was `"available-region-list"`) - -## File and Folder Reorganization - -### Deploy Area File Structure Changes - -#### Current Structure: -``` -areas/deploy/src/AzureMcp.Deploy/ -├── Commands/ -│ ├── AzdAppLogGetCommand.cs -│ ├── GenerateArchitectureDiagramCommand.cs -│ ├── IaCRulesGetCommand.cs -│ ├── PipelineGenerateCommand.cs -│ └── PlanGetCommand.cs -├── Options/ -│ ├── AzdAppLogOptions.cs -│ ├── GenerateArchitectureDiagramOptions.cs -│ ├── IaCRulesOptions.cs -│ ├── PipelineGenerateOptions.cs -│ └── PlanGetOptions.cs -└── Services/ - └── [various service files] -``` - -#### Target Structure (Hierarchical Organization): -``` -areas/deploy/src/AzureMcp.Deploy/ -├── Commands/ -│ ├── App/ -│ │ └── LogsGetCommand.cs (renamed from AzdAppLogGetCommand.cs) -│ ├── Infrastructure/ -│ │ └── RulesGetCommand.cs (renamed from IaCRulesGetCommand.cs) -│ ├── Pipeline/ -│ │ └── GuidanceGetCommand.cs (renamed from PipelineGenerateCommand.cs) -│ ├── Plan/ -│ │ └── GetCommand.cs (renamed from PlanGetCommand.cs) -│ └── Architecture/ -│ └── DiagramGenerateCommand.cs (renamed from GenerateArchitectureDiagramCommand.cs) -├── Options/ -│ ├── App/ -│ │ └── LogsGetOptions.cs (renamed from AzdAppLogOptions.cs) -│ ├── Infrastructure/ -│ │ └── RulesGetOptions.cs (renamed from IaCRulesOptions.cs) -│ ├── Pipeline/ -│ │ └── GuidanceGetOptions.cs (renamed from PipelineGenerateOptions.cs) -│ ├── Plan/ -│ │ └── GetOptions.cs (renamed from PlanGetOptions.cs) -│ └── Architecture/ -│ └── DiagramGenerateOptions.cs (renamed from GenerateArchitectureDiagramOptions.cs) -├── Templates/ (new directory) -│ ├── InfrastructureRulesTemplate.md -│ ├── PipelineGuidanceTemplate.md -│ └── DeploymentPlanTemplate.md -└── Services/ - ├── ITemplateService.cs (new interface) - ├── TemplateService.cs (new implementation) - └── [existing service files] -``` - -### Quota Area File Structure Changes - -#### Current Structure: -``` -areas/quota/src/AzureMcp.Quota/ -├── Commands/ -│ ├── RegionCheckCommand.cs -│ └── UsageCheckCommand.cs -├── Options/ -│ ├── RegionCheckOptions.cs -│ └── UsageCheckOptions.cs -└── Services/ - └── [various service files] -``` - -#### Target Structure (Hierarchical Organization): -``` -areas/quota/src/AzureMcp.Quota/ -├── Commands/ -│ ├── Usage/ -│ │ └── CheckCommand.cs (renamed from UsageCheckCommand.cs) -│ └── Region/ -│ └── AvailabilityListCommand.cs (renamed from RegionCheckCommand.cs) -├── Options/ -│ ├── Usage/ -│ │ └── CheckOptions.cs (renamed from UsageCheckOptions.cs) -│ └── Region/ -│ └── AvailabilityListOptions.cs (renamed from RegionCheckOptions.cs) -└── Services/ - └── [existing service files] -``` - -### Detailed File Rename Mapping - -#### Deploy Area Command Files: -| Current File | New File | New Class Name | Purpose | -|--------------|----------|----------------|---------| -| `Commands/AzdAppLogGetCommand.cs` | `Commands/App/LogsGetCommand.cs` | `LogsGetCommand` | Get logs from AZD-deployed applications | -| `Commands/IaCRulesGetCommand.cs` | `Commands/Infrastructure/RulesGetCommand.cs` | `RulesGetCommand` | Get Infrastructure as Code rules and guidelines | -| `Commands/PipelineGenerateCommand.cs` | `Commands/Pipeline/GuidanceGetCommand.cs` | `GuidanceGetCommand` | Get CI/CD pipeline guidance and configuration | -| `Commands/PlanGetCommand.cs` | `Commands/Plan/GetCommand.cs` | `GetCommand` | Generate Azure deployment plans | -| `Commands/GenerateArchitectureDiagramCommand.cs` | `Commands/Architecture/DiagramGenerateCommand.cs` | `DiagramGenerateCommand` | Generate Azure architecture diagrams | - -#### Deploy Area Option Files: -| Current File | New File | New Class Name | Purpose | -|--------------|----------|----------------|---------| -| `Options/AzdAppLogOptions.cs` | `Options/App/LogsGetOptions.cs` | `LogsGetOptions` | Options for app log retrieval | -| `Options/IaCRulesOptions.cs` | `Options/Infrastructure/RulesGetOptions.cs` | `RulesGetOptions` | Options for IaC rules retrieval | -| `Options/PipelineGenerateOptions.cs` | `Options/Pipeline/GuidanceGetOptions.cs` | `GuidanceGetOptions` | Options for pipeline guidance | -| `Options/PlanGetOptions.cs` | `Options/Plan/GetOptions.cs` | `GetOptions` | Options for deployment planning | -| `Options/GenerateArchitectureDiagramOptions.cs` | `Options/Architecture/DiagramGenerateOptions.cs` | `DiagramGenerateOptions` | Options for diagram generation | - -#### Quota Area Command Files: -| Current File | New File | New Class Name | Purpose | -|--------------|----------|----------------|---------| -| `Commands/UsageCheckCommand.cs` | `Commands/Usage/CheckCommand.cs` | `CheckCommand` | Check Azure resource usage and quotas | -| `Commands/RegionCheckCommand.cs` | `Commands/Region/AvailabilityListCommand.cs` | `AvailabilityListCommand` | List available regions for resource types | - -#### Quota Area Option Files: -| Current File | New File | New Class Name | Purpose | -|--------------|----------|----------------|---------| -| `Options/UsageCheckOptions.cs` | `Options/Usage/CheckOptions.cs` | `CheckOptions` | Options for usage checking | -| `Options/RegionCheckOptions.cs` | `Options/Region/AvailabilityListOptions.cs` | `AvailabilityListOptions` | Options for region availability listing | - -### Test File Updates Required - -#### Deploy Area Test Files: -``` -areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/ -├── App/ -│ └── LogsGetCommandTests.cs (update from AzdAppLogGetCommandTests.cs) -├── Infrastructure/ -│ └── RulesGetCommandTests.cs (update from IaCRulesGetCommandTests.cs) -├── Pipeline/ -│ └── GuidanceGetCommandTests.cs (update from PipelineGenerateCommandTests.cs) -├── Plan/ -│ └── GetCommandTests.cs (update from PlanGetCommandTests.cs) -└── Architecture/ - └── DiagramGenerateCommandTests.cs (update from GenerateArchitectureDiagramCommandTests.cs) -``` - -#### Quota Area Test Files: -``` -areas/quota/tests/AzureMcp.Quota.UnitTests/Commands/ -├── Usage/ -│ └── CheckCommandTests.cs (update from UsageCheckCommandTests.cs) -└── Region/ - └── AvailabilityListCommandTests.cs (update from RegionCheckCommandTests.cs) -``` - -### Namespace Updates Required - -#### Deploy Area Namespaces: -- `AzureMcp.Deploy.Commands.App` for application-specific commands (logs) -- `AzureMcp.Deploy.Commands.Infrastructure` for Infrastructure as Code commands -- `AzureMcp.Deploy.Commands.Pipeline` for CI/CD pipeline commands -- `AzureMcp.Deploy.Commands.Plan` for deployment planning commands -- `AzureMcp.Deploy.Commands.Architecture` for architecture diagram commands -- `AzureMcp.Deploy.Options.App` for application command options -- `AzureMcp.Deploy.Options.Infrastructure` for infrastructure command options -- `AzureMcp.Deploy.Options.Pipeline` for pipeline command options -- `AzureMcp.Deploy.Options.Plan` for planning command options -- `AzureMcp.Deploy.Options.Architecture` for architecture command options -- `AzureMcp.Deploy.Services` for template and other services - -#### Quota Area Namespaces: -- `AzureMcp.Quota.Commands.Usage` for usage-related commands -- `AzureMcp.Quota.Commands.Region` for region-related commands -- `AzureMcp.Quota.Options.Usage` for usage command options -- `AzureMcp.Quota.Options.Region` for region command options - -### Project File Updates - -#### Deploy Area Project File: -```xml - - - - - - - - - -``` - -#### Quota Area Project File: -```xml - - - - -``` - -### Registration File Updates - -#### DeploySetup.cs: -- Update `using` statements for new namespaces -- Update command registration to use `CommandGroup` hierarchy -- Register new `ITemplateService` and extension services - -#### QuotaSetup.cs: -- Update `using` statements for new namespaces -- Update command registration to use `CommandGroup` hierarchy -- Register extension services - -## Integration with Existing Commands - -### Internal Service Integration - -**Add Extension Dependencies**: -```xml - - -``` - -**Service Registration**: -```csharp -// In area setup ConfigureServices methods -services.AddTransient(); -services.AddTransient(); -``` - -### Command Implementation Updates - -**Example: AzdAppLogGetCommand using existing AzdCommand**: -```csharp -public sealed class AzdAppLogGetCommand( - ILogger logger, - IAzdService azdService) : SubscriptionCommand() -{ - public override string Name => "logs"; - - protected override async Task ExecuteAsync(AzdAppLogOptions options, CancellationToken cancellationToken) - { - // Use existing AZD service to get environment info - var envResult = await azdService.ExecuteAsync("env list", options.WorkspaceFolder); - - // Use existing AZD service to get logs - var logsResult = await azdService.ExecuteAsync($"monitor logs --environment {options.AzdEnvName}", options.WorkspaceFolder); - - // Add value by filtering and formatting logs for specific app types - var filteredLogs = FilterLogsForAppTypes(logsResult.Output); - - return McpResult.Success(filteredLogs); - } -} -``` - -## Prompt Template Consolidation - -### Template System Enhancement - -**Objective**: Replace dynamic prompt construction with embedded markdown templates. - -**Implementation**: -1. Create `areas/deploy/src/AzureMcp.Deploy/Templates/` directory -2. Extract prompts to markdown files: - - `AzdRulesTemplate.md` - - `PipelineGuidanceTemplate.md` - - `DeploymentPlanTemplate.md` -3. Create injectable `ITemplateService` interface -4. Add embedded resources to project file - -**Template Service Interface**: -```csharp -public interface ITemplateService -{ - Task GetTemplateAsync(string templateName, object parameters = null); -} -``` - -### Deployment Planning Separation - -**Split PlanGetCommand Responsibilities**: -- **Keep**: Project analysis and service recommendations -- **Add**: Next steps with specific tool commands -- **Remove**: Direct file generation (.azure/plan.copilotmd) - -**Next Steps Response**: -```csharp -public class PlanAnalysisResult -{ - public string[] RecommendedServices { get; set; } - public string[] NextStepCommands { get; set; } // Specific azmcp commands to run - public string DeploymentStrategy { get; set; } - public string[] RequiredTools { get; set; } -} -``` - -## Implementation Action Plan - -### Phase 1: Priority 0 (Must Complete First) - -#### 1.1 Command Registration Refactoring -- **Priority**: P0 -- **Files**: `DeploySetup.cs`, `QuotaSetup.cs` -- **Action**: Replace flat registration with `CommandGroup` hierarchy -- **Validation**: Commands accessible via new structure - -#### 1.2 Command Name Updates -- **Priority**: P0 -- **Files**: All command class files -- **Action**: Update `Name` properties to single verbs -- **Validation**: Unit tests pass with new names - -#### 1.3 Build Verification -- **Priority**: P0 -- **Action**: `dotnet build AzureMcp.sln` -- **Expected**: Zero compilation errors - -### Phase 2: Integration and Enhancement - -#### 2.1 Extension Service Integration -- **Priority**: P1 -- **Action**: Add project references and service injection -- **Validation**: Commands use existing Az/Azd services internally - -#### 2.2 Test Updates -- **Priority**: P1 -- **Action**: Update unit and live tests for new structure -- **Validation**: All tests pass - -### Phase 3: Optional Enhancements - -#### 3.1 Template System -- **Priority**: P2 -- **Action**: Extract prompts to embedded resources -- **Validation**: Template loading works correctly - -#### 3.2 Documentation -- **Priority**: P2 -- **Action**: Update `azmcp-commands.md` and `new-command.md` -- **Validation**: Documentation reflects new structure - -## Test Scenarios - -### Comprehensive Manual Test Cases (30 Scenarios) - -#### Command Registration and Help Tests [PASS] - -1. **Deploy Command Group Help** - - **Command**: `azmcp deploy --help` - - **Expected**: Shows deploy subcommands (app, infrastructure, pipeline, plan, architecture) - - **Validation**: All 5 subcommand groups are listed - -2. **Quota Command Group Help** - - **Command**: `azmcp quota --help` - - **Expected**: Shows quota subcommands (usage, region) - - **Validation**: Both subcommand groups are listed - -3. **Deploy App Commands Help** - - **Command**: `azmcp deploy app --help` - - **Expected**: Shows app subcommands (logs) - - **Validation**: logs command is available - -4. **Deploy Infrastructure Commands Help** - - **Command**: `azmcp deploy infrastructure --help` - - **Expected**: Shows infrastructure subcommands (rules) - - **Validation**: rules command is available - -5. **Deploy Pipeline Commands Help** - - **Command**: `azmcp deploy pipeline --help` - - **Expected**: Shows pipeline subcommands (guidance) - - **Validation**: guidance command is available - -#### Quota Command Tests [PASS] - -6. **Quota Usage Check - Valid Subscription** - - **Command**: `azmcp quota usage check --subscription 12345678-1234-1234-1234-123456789abc --region eastus --resource-type Microsoft.Compute/virtualMachines` - - **Expected**: Returns quota usage information for the subscription - - **Validation**: JSON output with quota data - -7. **Quota Usage Check - Invalid Subscription** - - **Command**: `azmcp quota usage check --subscription invalid-sub-id` - - **Expected**: Returns authentication or validation error - - **Validation**: subscription is not required - -8. **Region Availability List - Specific Resource** - - **Command**: `azmcp quota region availability list --resource-type Microsoft.Compute/virtualMachines` - - **Expected**: Returns list of regions where VMs are available - - **Validation**: JSON array of region names - -9. **Region Availability List - All Resources** - - **Command**: `azmcp quota region availability list` - - **Expected**: Returns general region availability information - - **Validation**: Clear error message about missing resource type - - -#### Deploy App Commands Tests [PASS: Covered in automation] - -11. **App Logs Get - Valid AZD Environment** - - **Command**: `azmcp deploy app logs get --workspace-folder ./myapp --azd-env-name dev` - - **Expected**: Returns application logs from AZD-deployed environment - - **Validation**: Log entries with timestamps - -12. **App Logs Get - Invalid Environment** - - **Command**: `azmcp deploy app logs get --workspace-folder ./myapp --azd-env-name nonexistent` - - **Expected**: Returns error about environment not found - - **Validation**: Clear error message - -13. **App Logs Get - No AZD Project** - - **Command**: `azmcp deploy app logs get --workspace-folder ./empty-folder` - - **Expected**: Returns error about missing AZD project - - **Validation**: Error indicates no azure.yaml found - -14. **App Logs Get with Service Filter** - - **Command**: `azmcp deploy app logs get --workspace-folder ./myapp --azd-env-name dev --service-name api` - - **Expected**: Returns logs filtered to specific service - - **Validation**: Logs only from specified service - -#### Deploy Infrastructure Commands Tests [PASS] - -15. **Infrastructure Rules Get - Bicep Project** - - **Command**: `azmcp deploy infrastructure rules get ` - - **Expected**: Returns Bicep-specific IaC rules and recommendations - - **Validation**: Rules specific to Bicep templates - -16. **Infrastructure Rules Get - Terraform Project** - - **Command**: `azmcp deploy infrastructure rules get` - - **Expected**: Returns Terraform-specific IaC rules and recommendations - - **Validation**: Rules specific to Terraform configuration - -#### Deploy Pipeline Commands Tests [PASS] - -19. **Pipeline Guidance Get - GitHub Project** - - **Command**: `azmcp deploy pipeline guidance get --workspace-folder ./github-project` - - **Expected**: Returns GitHub Actions CI/CD pipeline guidance - - **Validation**: GitHub-specific workflow recommendations - -20. **Pipeline Guidance Get - Azure DevOps Project** - - **Command**: `azmcp deploy pipeline guidance get --workspace-folder ./azdo-project` - - **Expected**: Returns Azure DevOps pipeline guidance - - **Validation**: Azure Pipelines YAML recommendations - -21. **Pipeline Guidance Get - No VCS Project** - - **Command**: `azmcp deploy pipeline guidance get --workspace-folder ./no-git` - - **Expected**: Returns general CI/CD guidance - - **Validation**: Platform-agnostic recommendations - -#### Deploy Plan Commands Tests - -22. **Plan Get - .NET Project** [Pass] - - **Command**: `azmcp deploy plan get --raw-mcp-tool-input {}` - - **Expected**: Returns deployment plan specific to .NET applications - - **Validation**: Recommendations for App Service or Container Apps - - - -#### Deploy Architecture Commands Testsv [PASS] - -26. **Architecture Diagram Generate - Simple App** - - **Command**: `azmcp deploy architecture diagram generate --workspace-folder ./simple-app` - - **Expected**: Returns Mermaid diagram for simple application architecture - - **Validation**: Valid Mermaid syntax with basic components - -27. **Architecture Diagram Generate - Microservices** - - **Command**: `azmcp deploy architecture diagram generate --workspace-folder ./microservices` - - **Expected**: Returns complex Mermaid diagram with multiple services - - **Validation**: Comprehensive diagram with service relationships - -28. **Architecture Diagram Generate with Custom Options** - - **Command**: `azmcp deploy architecture diagram generate --workspace-folder ./myapp --include-networking --include-security` - - **Expected**: Returns detailed diagram including network and security components - - **Validation**: Enhanced diagram with additional layers - -#### Error Handling and Edge Cases [PASS] - -29. **Invalid Command Structure (Legacy Format)** - - **Command**: `azmcp deploy plan-get --workspace-folder ./myapp` - - **Expected**: Command not found error - - **Validation**: Clear error indicating command format change - -30. **Missing Required Parameters** - - **Command**: `azmcp quota usage check` - - **Expected**: Returns error about missing required subscription parameter - - **Validation**: Clear parameter requirement message - -### Integration and Extension Service Tests - -#### Extension Integration Tests [PASS] (bellow command has no duplicated command in azd/az) - -31. **AZD Service Integration** - - **Scenario**: Verify deploy commands use existing AzdCommand internally - - **Command**: `azmcp deploy app logs get --workspace-folder ./azd-project` - - **Expected**: Command successfully executes - - **Validation**: No duplication of AZD functionality - -32. **Azure CLI Service Integration** - - **Scenario**: Verify quota commands use existing AzCommand internally - - **Command**: `azmcp quota usage check --subscription-id ` - - **Expected**: Command successfully executes Azure CLI operations via extension - - **Validation**: Structured output from Azure CLI data - -### Performance and Reliability Tests [PASS] - -33. **Large Project Analysis** - - **Command**: `azmcp deploy plan get --workspace-folder ./large-enterprise-app` - - **Expected**: Command completes within reasonable time (< 30 seconds) - - **Validation**: Response time and memory usage within limits, the analysis is performed by agent, the tool response quickly with the plan template. - -34. **Concurrent Command Execution** - - **Scenario**: Run multiple commands simultaneously - - **Commands**: Multiple instances of quota and deploy commands - - **Expected**: All commands complete successfully without conflicts - - **Validation**: No resource contention or errors - -### Authentication and Authorization Tests [PASS] - -35. **Unauthenticated Azure Access** - - **Command**: `azmcp quota usage check --subscription-id ` - - **Expected**: Clear authentication error when not logged into Azure - - **Validation**: Helpful error message with login instructions - -36. **Insufficient Permissions** - - **Command**: `azmcp quota usage check --subscription-id ` - - **Expected**: Permission denied error with clear explanation - - **Validation**: Specific permission requirements listed - -### Template and Output Format Tests - -37. **JSON Output Format** - - **Command**: `azmcp quota usage check --subscription-id --output json` - - **Expected**: Well-formed JSON response - - **Validation**: Valid JSON structure - -38. **Markdown Output Format** - - **Command**: `azmcp deploy plan get --workspace-folder ./myapp --output markdown` - - **Expected**: Formatted markdown response - - **Validation**: Proper markdown structure with headers and lists - -### Cross-Platform Tests - -39. **Windows PowerShell Execution** - - **Scenario**: Execute commands in Windows PowerShell environment - - **Command**: All deploy and quota commands - - **Expected**: Commands execute successfully on Windows - - **Validation**: No platform-specific errors - -40. **Linux/macOS Execution** - - **Scenario**: Execute commands in bash/zsh environment - - **Command**: All deploy and quota commands - - **Expected**: Commands execute successfully on Unix-like systems - - **Validation**: Cross-platform compatibility - -### Copilot Natural Language Test Prompts - -The following prompts can be used with GitHub Copilot or VS Code Copilot to test the deployment and quota functionality through natural language interactions. These prompts validate that the MCP tools are properly integrated and accessible through conversational interfaces. - -#### Quota Management Prompts - -1. **Basic Quota Check** - - **Prompt**: "Check my Azure quota usage for subscription 12345678-1234-1234-1234-123456789abc" - - **Expected**: Copilot uses `azmcp quota usage check` command - - **Validation**: Returns structured quota information - - **Test Result**: Pass - - **Test Observation**: Agent call the tool to check quota usage for the resource types that inferred from the project. - It would call this tool multiple times for different regions as it is not specified in the prompt. - -2. **Region Availability Query** - - **Prompt**: "What regions are available for virtual machines in Azure?" - - **Expected**: Copilot uses `azmcp quota region availability list` command - - **Validation**: Returns list of regions with VM availability - - **Test Result**: Pass - -3. **Resource-Specific Quota** - - **Prompt**: "Check quota limits for compute resources in my Azure subscription" - - **Expected**: Copilot uses quota commands with appropriate filters - - **Validation**: Returns compute-specific quota data - - **Test Result**: Pass - -4. **Regional Capacity Planning** - - **Prompt**: "I need to deploy 100 VMs - which Azure regions have capacity?" - - **Expected**: Copilot uses region availability and quota commands - - **Validation**: Provides capacity recommendations - - **Test Result**: Pass - -#### Deployment Planning Prompts - -5. **Application Deployment Planning** - - **Prompt**: "Help me plan deployment for my .NET web application to Azure" - - **Expected**: Copilot uses `azmcp deploy plan get` command - - **Validation**: Returns deployment recommendations for .NET apps - - **Model**: Claude Sonnet 4 - - **Project Context**: ESHOPWEB project with .NET. Bicep files are present. - - **Test Result**: Pass - - **Test Observation**: - Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get - Terminals called during the plan execution: az account show, azd auth login --check-status, azd env list, azd init --environment eshop-dev, azd env set AZURE_LOCATION eastus, azd provision --preview, azd up - -6. **Microservices Architecture Planning** - - **Prompt**: "Generate a deployment plan for my microservices application" - - **Expected**: Copilot uses deployment planning tools - - **Validation**: Returns multi-service deployment strategy - - **Model**: Claude Sonnet 4 - - **Project Context**: EXAMPLE-VOTING-APP project with .NET, python. Three micro services. Bicep files not present. - - **Test Result**: Pass - - **Test Observation**: - Plan generated correctly with the microservices defined as container apps in one environment. - Tools called during the plan creation: deploy plan get, iac rules get - Tools called during the plan execution: iac rules get - Terminals called during the plan execution: azd version, azd init, azd env list, azd up - -7. **Infrastructure as Code Guidance** - - **Prompt**: "What are the best practices for Bicep templates in my project?" - - **Expected**: Copilot uses `azmcp deploy infrastructure rules get` command - - **Validation**: Returns Bicep-specific recommendations - - **Test Result**: Pass - - **Test Observation**: - Tools called: bicepschema get, bestpractices get, deploy iac rules get - Agent aggregated the rules from the Bicep schema best practices and iac rule, returned them in a single response. - -8. **CI/CD Pipeline Setup** - - **Prompt**: "Help me set up CI/CD for my GitHub project deploying to Azure" - - **Expected**: Copilot uses `azmcp deploy pipeline guidance get` command - - **Validation**: Returns GitHub Actions workflow recommendations - - **Test Result**: Pass - - **Test Observation**: - Tools called: deploy pipeline guidance get - Terminals called: azd pipeline config - `azd pipeline config` error: resolving bicep parameters file: substituting environment variables for environmentName: unable to parse variable name - Agent failed to resolve this error, so it switch to its own solution to setup pipeline with az command - -#### Architecture and Visualization Prompts - -9. **Architecture Diagram Generation** - - **Prompt**: "Create an architecture diagram for my application deployment" - - **Expected**: Copilot uses `azmcp deploy architecture diagram generate` command - - **Validation**: Returns Mermaid diagram - - **Test Result**: Pass - -10. **Complex System Visualization** - - **Prompt**: "Generate a detailed architecture diagram including networking and security" - - **Expected**: Copilot uses diagram generation with enhanced options - - **Validation**: Returns comprehensive diagram - - **Test Result**: Fail - - **Test Observation**: `azmcp deploy architecture diagram generate` cannot handle complex architecture with networking and security components. So it returned a simple diagram without those components. - Added tool description to tell agent that it cannot handle complex architecture with networking and security components. - -#### Application Monitoring Prompts - -11. **Application Log Analysis** - - **Prompt**: "Show me logs from my AZD-deployed application in the dev environment" - - **Expected**: Copilot uses `azmcp deploy app logs get` command - - **Validation**: Returns filtered application logs - - **Test Result**: Pass - -12. **Service-Specific Monitoring** - - **Prompt**: "Get logs for the API service in my containerized application" - - **Expected**: Copilot uses app logs command with service filtering - - **Validation**: Returns service-specific log data - - **Test Result**: Pass - -#### Multi-Step Workflow Prompts - -13. **End-to-End Deployment Workflow** - - **Prompt**: "I have a new Python app - help me deploy it to Azure from scratch" - - **Expected**: Copilot uses multiple commands (plan, infrastructure, pipeline) - - **Validation**: Provides step-by-step deployment guidance - - **Test Result**: Pass - - **Test Observation**: - Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get - -14. **Capacity Planning Workflow** - - **Prompt**: "Plan Azure resources for a high-traffic e-commerce application" - - **Expected**: Copilot uses quota, planning, and architecture tools - - **Validation**: Comprehensive capacity and architecture recommendations - - **Test Result**: Pass - - **Test Observation**: - Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get - Agent designed an azure architecture with Azure Front Door and Azure CDN for high traffic, the backend is using ACA. - -15. **Troubleshooting Workflow** - - **Prompt**: "My Azure deployment is failing - help me diagnose the issue" - - **Expected**: Copilot uses logs, quota, and diagnostic commands - - **Validation**: Systematic troubleshooting approach - - **Test Result**: Pass - -#### Technology-Specific Prompts - -16. **Node.js Application Deployment** - - **Prompt**: "Deploy my Node.js Express app to Azure with best practices" - - **Expected**: Copilot provides Node.js-specific deployment plan - - **Validation**: Appropriate Azure service recommendations - - **Test Result**: Pass - -17. **Container Deployment Strategy** - - **Prompt**: "What's the best way to deploy my Docker containers to Azure?" - - **Expected**: Copilot recommends container-specific Azure services - - **Validation**: Container-optimized deployment strategy - - **Test Result**: Pass - - **Test Observation**: - Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get, documentation search - Agent recommended aca, app service for container, aks and compared the differences. - - -18. **Database Integration Planning** - - **Prompt**: "Plan deployment including a PostgreSQL database for my web app" - - **Expected**: Copilot includes database services in deployment plan - - **Validation**: Integrated database and application deployment - - **Test Result**: Pass - - **Test Observation**: - Tools called during the plan creation: deploy plan get, bicep schema get, bestpractices get, deploy iac rules get - Agent created a deployment plan with PostgreSQL database and recommended using Azure Database for PostgreSQL Flexible Server. - -#### Error Handling and Edge Case Prompts - -19. **Invalid Project Context** - - **Prompt**: "Generate deployment plan for this empty folder" - - **Expected**: Copilot handles missing project context gracefully - - **Validation**: Appropriate error handling and guidance - - **Test Result**: Pass - - **Test Observation**:Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get - Agent created a deployment plan with general recommendations, even though the folder is empty. - -20. **Authentication Issues** - - **Prompt**: "Check my Azure quotas (when not authenticated)" - - **Expected**: Copilot provides clear authentication guidance - - **Validation**: Helpful error messages and login instructions - - **Test Result**: Pass - -#### Advanced Integration Prompts - -21. **Cross-Service Integration** - - **Prompt**: "Plan deployment for this project, use function for the backend service, use app service for the frontend service" - - **Expected**: Copilot coordinates multiple Azure services - - **Validation**: Integrated multi-service architecture - - **Test Result**: Pass - - **Test Observation**: Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get - Agent created a deployment plan with Azure Functions for backend and Azure App Service for frontend. - -22. **Compliance and Security Focus** - - **Prompt**: "Deploy my healthcare app with HIPAA compliance requirements" - - **Expected**: Copilot emphasizes security and compliance features - - **Validation**: Security-focused deployment recommendations - - **Test Result**: Pass - - **Test Observation**: Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get - Agent created a deployment plan with HIPAA Requirements: Data encryption, access audit trails, secure communication, identity management - -23. **Cost Optimization Planning** - - **Prompt**: "Plan cost-effective Azure deployment for my startup application" - - **Expected**: Copilot recommends cost-optimized services and configurations - - **Validation**: Budget-conscious deployment strategy - - **Test Result**: Pass - - **Test Observation**: Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get - Agent created a deployment plan with ACA consumption plan. - -24. **Scaling Strategy Development** - - **Prompt**: "Plan Azure deployment that can scale from 1000 to 1 million users" - - **Expected**: Copilot provides scalable architecture recommendations - - **Validation**: Auto-scaling and performance considerations - - **Test Result**: Pass - - **Test Observation**: Tools called during the plan creation: deploy plan get, bestpractices get, deploy iac rules get - Agent created a deployment plan with AKS and its Horizontal Pod Autoscaler (HPA) Configuration. - -25. **Multi-Environment Strategy** - - **Prompt**: "Set up dev, staging, and production environments for my app" - - **Expected**: Copilot provides multi-environment deployment strategy - - **Validation**: Environment-specific configurations and pipelines - - **Test Result**: Pass - - **Test Observation**: Tools called: best practices get, azd learn, azd config show, azd init - -#### Integration Testing Prompts - -26. **Tool Integration Validation** - - **Prompt**: "Use Azure CLI to check my subscription then plan deployment" - - **Expected**: Copilot seamlessly integrates existing AZ commands with new tools - - **Validation**: No duplication of CLI functionality - - **Test Result**: Pass - - **Test Observation**: Tools called az account show, subscription list, deploy plan get - -27. **AZD Integration Testing** - - **Prompt**: "Get logs from my azd-deployed application and plan next deployment" - - **Expected**: Copilot uses existing AZD integration effectively - - **Validation**: Proper integration with existing AZD commands - - **Test Result**: Pass - -28. **Command Discovery Testing** - - **Prompt**: "What deployment tools are available in this Azure MCP server?" - - **Expected**: Copilot lists available deployment and quota commands - - **Validation**: Complete tool discovery and explanation - - **Test Result**: Pass - - **Test Observation**: Tools called: deploy learn, azd learn - Agent provided a list of azd commands and the deploy plan tool as specialized deployment tool. - -#### Performance and Reliability Prompts - -29. **Large Project Handling** - - **Prompt**: "Analyze deployment requirements for this enterprise monorepo" - - **Expected**: Copilot handles complex project analysis efficiently - - **Validation**: Reasonable response time and comprehensive analysis - - **Test Result**: Pass - - **Test Observation**: Tools called: deploy plan get, bestpractices get, deploy iac rules get - Agent is responsible to analyze the monorepo, the tools responded fast with the static template for the target service. - -30. **Concurrent Operation Testing** - - **Prompt**: "Check quotas while generating architecture diagram and planning deployment" - - **Expected**: Copilot handles multiple concurrent operations - - **Validation**: All operations complete successfully without conflicts - - **Test Result**: Pass - - **Test Observation**: Tools called: subscription list, quota available region list, quota usage get, deploy architecture diagram generate - - -### Expected Copilot Behavior Patterns - -When testing with these prompts, validate that Copilot: - -1. **Command Selection**: Chooses appropriate azmcp commands based on user intent -2. **Parameter Handling**: Correctly infers or prompts for required parameters -3. **Error Handling**: Provides helpful guidance when commands fail -4. **Integration**: Uses existing extension commands when appropriate -5. **Output Processing**: Formats and explains command results clearly -6. **Follow-up Actions**: Suggests logical next steps after command execution -7. **Context Awareness**: Considers project structure and environment in recommendations - -## Validation Criteria - -### Build and Test Requirements - -- [ ] `dotnet build AzureMcp.sln` succeeds with zero errors -- [ ] All unit tests pass -- [ ] Live tests pass (when Azure credentials available) -- [ ] CLI help commands work for all new structures - -### Command Structure Compliance - -- [ ] All commands follow ` ` pattern -- [ ] No hyphenated command names -- [ ] Hierarchical `CommandGroup` registration used -- [ ] Command names are single verbs (`get`, `list`, `generate`, etc.) - -### Integration Requirements - -- [ ] Deploy commands use existing Extension services internally -- [ ] No duplication of Az/Azd CLI functionality -- [ ] Value-added services provide structured guidance -- [ ] Clear differentiation between guided vs direct CLI access - -### Documentation Standards - -- [ ] All commands documented in `azmcp-commands.md` -- [ ] Examples show new command structure -- [ ] No migration-from-legacy section required; document only the new hierarchical structure -- [ ] Integration patterns documented in `new-command.md` - -## Post-Implementation Considerations - -### Future Architecture Evolution - -1. **AZD MCP Server Migration**: When Azure Developer CLI creates their own MCP server, evaluate migrating AZD-specific tools -2. **Template System Enhancement**: Expand template system for more dynamic content generation -3. **Cross-Area Integration**: Explore integration between deploy and quota areas -4. **Performance Optimization**: Cache quota information and template loading - -## Follow-up Issue Creation - -For every P1 or P2 action that remains open after PR #626: - -- Create a GitHub issue titled: "[P1|P2] : " -- Apply labels: `area/deploy` or `area/quota` (and others as needed), `priority/P1` or `priority/P2`, and `PR/626-followup` -- Include in the body: problem statement, acceptance criteria, links to exact files/lines and to `docs/PR-626-Action-Plan.md`, owner, and due date. -- Cross-link the issue in PR #626 and tick the matching item in the merge-readiness checklist when complete. - -### Monitoring and Metrics - -1. **Command Usage**: Track which new commands are most/least used -2. **Error Patterns**: Monitor common failure scenarios for improvement -3. **Integration Success**: Measure successful extension service integration -4. **User Feedback**: Collect feedback on new command structure - -## Conclusion - -This refactoring plan addresses identified standards violations while preserving the deployment and quota management capabilities introduced in PR #626. The changes include: - -1. **Proper Command Structure**: Hierarchical `CommandGroup` registration following established patterns -2. **Standard Naming**: ` ` pattern without hyphens -3. **Integration**: Leverage existing Extension commands to avoid duplication -4. **Value-Added Services**: Focus on structured guidance and templates rather than raw CLI access - -The implementation will proceed with the priority 0 items first to ensure build stability, followed by integration enhancements and optional improvements. This approach maintains the capabilities while aligning with repository standards and architectural patterns. diff --git a/docs/PRReviewNotes.md b/docs/PRReviewNotes.md deleted file mode 100644 index 46a69d1df..000000000 --- a/docs/PRReviewNotes.md +++ /dev/null @@ -1,288 +0,0 @@ -# Code Review Report: PR #626 — Deploy and Quota Commands - -This report reviews PR #626 against the guidance in `docs/PR-626-Final-Recommendations.md` and repository standards. It summarizes what’s aligned, what’s missing, risks, and concrete next steps to reach compliance and improve maintainability. - -## Review checklist - -- [ ] Command groups follow hierarchical structure and consistent naming -- [ ] Command names use pattern (no hyphens) -- [ ] Files/namespaces reorganized per target structure -- [ ] Integration leverages existing extension services (az/azd) where applicable -- [ ] AOT/trimming safety validated (no RequiresDynamicCode violations) -- [ ] Uses System.Text.Json (no Newtonsoft) -- [ ] Tests updated and sufficient coverage -- [ ] Documentation updated (commands and prompts) - Note: No migration-from-legacy section is required; we only document the new hierarchical structure. -- [ ] CHANGELOG and spelling checks updated - -## Findings - -### 1) Command structure and naming - -- Deploy area now uses hierarchical groups and verbs: - - deploy app logs get - - deploy infrastructure rules get - - deploy pipeline guidance get - - deploy plan get - - deploy architecture diagram generate -- Quota area uses hierarchical groups and verbs: - - quota usage check - - quota region availability list -- Registration uses CommandGroup with nested subgroups for both areas. Good. -- Minor issue: the top-level deploy CommandGroup description string still references legacy hyphenated names (plan_get, iac_rules_get, etc.). This is cosmetic but inconsistent with the new pattern. - -Status: Mostly PASS (fix description text). - -### 2) File/folder organization and namespaces - -- Deploy: Commands and Options are placed under App/Infrastructure/Pipeline/Plan/Architecture subfolders. Services and Templates folders added. JsonSourceGeneration context present. Good. -- Quota: Commands moved under Usage/ and Region/, Options split accordingly. Good. - -Status: PASS. - -### 3) Integration with existing extension commands - -- Guidance recommends reusing the existing extension (Az/Azd) services to avoid duplication and clarify ownership. Current DeployService calls a local AzdResourceLogService to fetch logs via workspace parsing + Monitor Query. There’s no explicit reuse of the extension Az/Azd command surface. -- The PR’s intent acknowledges that azd-app-logs is temporary until azd exposes a native command. That’s fine short-term, but we should still abstract this behind interfaces and consider delegating through the extension service to reduce duplication and future migrations. - -Status: PARTIAL. Needs refactor to consume extension services (IAzService/IAzdService) or document/ticket the temporary approach with deprecation plan. - -### 4) AOT/trimming safety - -- Projects declare true. -- Uses System.Text.Json source generation in Deploy (DeployJsonContext) and Quota (QuotaJsonContext). Good. -- YamlDotNet is used, but via low-level parser (events) rather than POCO deserialization. This reduces reflection/AOT risk, but we should still run the AOT analyzer script to confirm there are no warnings and consider linker descriptors if needed. -- Reflection is used to load embedded resources (GetManifestResourceStream). That’s typically safe with embedded resources; ensure resources are included (they are) and names are stable. - -Status: LIKELY PASS (verify with analyzer). Action item to run eng/scripts/Analyze-AOT-Compact.ps1 and address any findings. - -### 5) JSON library choice - -- System.Text.Json is used across new code. No Newtonsoft dependencies in these areas. Good. - -Status: PASS. - -### 6) Tests - -- Unit tests added for Quota commands (AvailabilityListCommandTests, CheckCommandTests) and Deploy command tests exist for the reorganized classes (LogsGetCommandTests, RulesGetCommandTests, GuidanceGetCommandTests, etc.). -- Tests validate parsing, happy paths, errors, and output shapes. Nice. - -Status: PASS (keep adding targeted edge cases; see gaps below). - -### 7) Documentation - -- docs/azmcp-commands.md updated to include Deploy/Quota sections. -- Issue: one example contains a duplicated group token: “azmcp quota quota region availability list”. -- E2E prompts updated; several questions remain unresolved in PR comments (e.g., value-add vs existing tools, pipeline setup guidance). Document the new hierarchical structure explicitly; no migration-from-legacy content is needed. - -Status: PARTIAL. Fix command typos and document the new hierarchical structure (no migration section). - -### 8) CHANGELOG and spelling - -- PR checklist flags CHANGELOG and spelling as incomplete. These need to be completed before merge. - -Status: FAIL (to be addressed). - -## Gaps and risks - -- Integration duplication: DeployService/AzdResourceLogService duplicates behavior that would better live behind extension services. Risk of divergence and confusion with azd MCP server effort. Mitigate by factoring through IAzService/IAzdService and marking the logs command as temporary. -- Documentation accuracy: Minor typos/conflicts can mislead users (e.g., duplicated “quota” token). Add migration notes to reduce confusion for users familiar with prior hyphenated commands. -- AOT/trimming: While likely safe, adding YamlDotNet warrants a quick AOT scan and consideration of linker configs if warnings appear. -- CI failures: Current PR pipeline shows failures for several platform builds (linux_x64, osx_x64, win_x64). Investigate before merge. - -## Targeted recommendations and next steps - -P0 (must do before merge) -- Fix deploy CommandGroup description to remove legacy hyphenated names and align with actual subcommands. -- Fix docs/azmcp-commands.md typos (“azmcp quota quota region availability list” → “azmcp quota region availability list”). -- Add CHANGELOG entry summarizing new areas (deploy/quota), command structure, and any breaking command name changes. -- Run spelling check: .\\eng\\common\\spelling\\Invoke-Cspell.ps1 and address findings. -- Investigate the CI failures on the PR (linux_x64, osx_x64, win_x64 jobs) and resolve. - -P1 (integration and maintainability) -- Abstract DeployService’s log retrieval to use extension services (IAzdService) where possible, or encapsulate current logic behind an interface to ease migration when azd exposes native logs. -- Consider adding a light project reference to the extension area if needed for reuse (as recommended), or explicitly document why it’s deferred. -- Provide help examples for each command and ensure output shape/casing are documented. -- Expand tests for: - - Architecture diagram: invalid/malformed raw-mcp-tool-input and large service graphs. - - Quota parsing: empty/whitespace resource-types; mixed casing; extreme list lengths. - -P2 (optional enhancements) -- Template system: Centralize all prompt content via TemplateService (you’ve started this); document template names and parameters; add unit tests for template retrieval. -- Performance: Consider caching region availability lookups and IaC rule templates where applicable. -- AOT verification: Add a short note to the PR description capturing AOT analysis results and any linker config changes (if needed). - -## Tracking directive: Create issues for all open P1/P2 - -For every item labeled P1 or P2 in this report that is not completed in PR #626: - -- Create a separate GitHub issue in the repository with: - - Title: "[P1|P2] : " - - Labels: `area/deploy` or `area/quota` (and others as appropriate), `priority/P1` or `priority/P2`, and `PR/626-followup` - - Body: Problem statement, acceptance criteria, links to the exact files/lines and to `docs/PR-626-Action-Plan.md`, plus owner and due date. -- Cross-link the issue in PR #626 and check the corresponding box in the "Exhaustive merge-readiness checklist" when done. - -Issue template snippet: - -- Problem: -- Scope: -- Acceptance Criteria: -- Links: · PR #626 · docs/PR-626-Action-Plan.md -- Owner: · Priority: P1|P2 · Labels: area/*, priority/*, PR/626-followup - -## Compliance matrix vs Final Recommendations - -- Command Groups (quota, deploy subgroups): Done. -- Command Structure Changes (verbs): Done; minor description text cleanup pending. -- Integration Strategy (reuse az/azd): Partially done; not yet wired to extension services. -- File/Folder Reorg: Done. -- Namespace Updates: Done. -- Project File Updates (embedded resources): Done. Extension project reference: Not added (consider per integration plan). -- Registration Updates: Done (areas registered in core setup). -- Template System: Implemented; continue consolidating prompts. -- Plan command scope: Current implementation returns a plan template; it no longer writes files directly. Aligned with guidance. - -## Quick quality gates snapshot - -- Build: PR pipeline shows failures on multiple x64 jobs (linux/osx/win). Needs investigation. -- Lint/Spelling: Spelling unchecked; run script and fix. -- Tests: New unit tests present; ensure they run in CI and are green after any fixes. -- Smoke/help: Verify `azmcp deploy --help` and `azmcp quota --help` show the expected hierarchy post-changes. - -## Appendix: Suggested documentation deltas - -- docs/azmcp-commands.md - - Fix: “azmcp quota quota region availability list” → “azmcp quota region availability list”. - - Add “Migration from legacy hyphenated names” table mapping plan-get → plan get, iac-rules-get → infrastructure rules get, etc. -- docs/new-command.md - - Include example of hierarchical registration via CommandGroup and guidance on naming. - ---- - -Completion summary -- The PR largely meets the architectural reorg, naming, and testability goals. The biggest remaining items are integration reuse (az/azd), small docs fixes, CHANGELOG/spelling, and CI stabilization. Addressing the P0/P1 items above should make this PR ready to merge. - -## Exhaustive merge-readiness checklist - -Note: Priority tags — [P0] must before merge, [P1] should before merge, [P2] nice-to-have. - -### Command design and UX -- [ ] [P0] Verify command hierarchy and verbs match guidance exactly: - - deploy app logs get - - deploy infrastructure rules get - - deploy pipeline guidance get - - deploy plan get - - deploy architecture diagram generate - - quota usage check - - quota region availability list -- [ ] [P0] Remove legacy/hyphenated names and outdated descriptions (e.g., DeploySetup group description). - - Files: areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs; areas/quota/src/AzureMcp.Quota/QuotaSetup.cs (verify help text) -- [ ] [P0] Ensure --help text is concise and consistent; clearly mark required vs optional options. -- [ ] [P1] Provide help examples for each command (typical + edge-case for raw-mcp-tool-input). -- [ ] [P1] Confirm output shapes and property casing are consistent and documented. - -### Options and input validation -- [ ] [P0] Validate raw-mcp-tool-input JSON: required fields present; unknown fields behavior defined; helpful errors. -- [ ] [P0] Validate quota inputs: region/resource types normalized; reject empty/invalid sets; friendly messages. -- [ ] [P0] Validate logs query time windows and limits; set sane defaults and max bounds. -- [ ] [P1] Add JSON schema snippets for raw-mcp-tool-input in docs and link from --help. -- [ ] [P2] Robust list parsers (comma/space/newline with trimming) + tests. - - Files: areas/deploy/src/AzureMcp.Deploy/Options/DeployOptionDefinitions.cs; areas/deploy/src/AzureMcp.Deploy/Options/App/LogsGetOptions.cs; areas/quota/src/AzureMcp.Quota/Commands/Usage/CheckCommand.cs; areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs - -### Code structure, AOT, and serialization -- [ ] [P0] Use primary constructors where applicable in new classes. -- [ ] [P0] Use System.Text.Json only; no Newtonsoft. -- [ ] [P0] Ensure all DTOs are covered by source-gen contexts (DeployJsonContext, QuotaJsonContext) in all (de)serializations. -- [ ] [P0] Prefer static members where possible; avoid reflection/dynamic not safe for AOT. -- [ ] [P0] Run AOT/trimming analysis; address warnings (preserve attributes/linker config if needed). -- [ ] [P1] Add JSON round-trip tests proving source-gen coverage. -- [ ] [P2] Enforce culture-invariant formatting/parsing (dates, numbers, casing). - - Files: areas/deploy/src/AzureMcp.Deploy/Commands/DeployJsonContext.cs; areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs - -### Services and Azure SDK usage -- [ ] [P0] Use IHttpClientFactory and reuse Azure SDK clients appropriately; avoid per-call instantiation. -- [ ] [P0] Use Azure SDK default retries/timeouts; avoid custom retries unless justified. -- [ ] [P0] Respect cloud/authority from environment; support sovereign clouds. -- [ ] [P0] Use TokenCredential correctly; do not accept/store secrets directly. -- [ ] [P1] Abstract log retrieval behind an interface and prefer routing via extension services (IAzService/IAzdService) to reduce duplication. -- [ ] [P2] Keep diagnostics minimal, opt-in, and scrub PII. - - Files: areas/deploy/src/AzureMcp.Deploy/Services/DeployService.cs; areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs - -### Templates, resources, and I/O -- [ ] [P0] Ensure embedded templates/rules are included and load via correct manifest names. -- [ ] [P0] Validate template outputs are non-null and meaningful; handle missing resource errors. -- [ ] [P1] Tests confirming presence and expected content of embedded resources. -- [ ] [P2] Cache static templates in-memory (thread-safe) to reduce I/O. - - Files: areas/deploy/src/AzureMcp.Deploy/Services/Templates/TemplateService.cs; areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs; areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs - -### Security and robustness -- [ ] [P0] Bound and sanitize inputs for diagram generation; warn or fail cleanly if payload exceeds safe URL length. -- [ ] [P0] Encode all URLs; avoid external calls based on untrusted input (Azure SDK excepted). -- [ ] [P1] Review YamlDotNet usage; handle malformed YAML with clear errors. -- [ ] [P1] Plumb CancellationToken through long-running operations (logs queries). -- [ ] [P2] Consider allowlist constraints for resource types/locations if applicable. - - Files: areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs; areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs - -### Testing -- [ ] [P0] Per-command tests (success + 1–2 error cases): - - deploy/app/logs/get (invalid YAML, empty resources, query timeout) - - deploy/infrastructure/rules/get (resource presence) - - deploy/plan/get (template present) - - deploy/architecture/diagram/generate (bad/large JSON, valid graph) - - quota/usage/check (invalid/empty resource types, mixed casing) - - quota/region/availability/list (filters, cognitive services variants) -- [ ] [P0] Tests to ensure JsonSerializerContext is used (no reflection fallback at runtime). -- [ ] [P0] Tests asserting output contracts (shape, casing, required properties). -- [ ] [P1] Integration tests with recorded fixtures/test proxy where feasible. -- [ ] [P1] Update E2E prompt tests with new commands and sample payloads. -- [ ] [P2] Concurrency and large-input perf smoke tests. - - Files (tests to extend/verify): areas/deploy/tests/AzureMcp.Deploy.UnitTests/**; areas/quota/tests/AzureMcp.Quota.UnitTests/**; core/tests/AzureMcp.Tests/** - -### Documentation -- [ ] [P0] Fix typos (e.g., docs/azmcp-commands.md “azmcp quota quota …” → “azmcp quota …”). -- [ ] [P0] Document each command: synopsis, options, example inputs/outputs, error cases, JSON schemas. -- [ ] [P0] Update CHANGELOG.md summarizing new areas/commands and notable behavior. -- [ ] [P1] Troubleshooting notes (auth issues, timeouts, payload too large for diagrams). -- [ ] [P2] Link to architecture decisions for diagram/templates. - - Files: docs/azmcp-commands.md; CHANGELOG.md; docs/new-command.md; docs/PR-626-Code-Review.md (this document) - -### Repo hygiene and engineering system -- [ ] [P0] One class/interface per file; remove dead code/unused usings; consistent naming. -- [ ] [P0] Ensure copyright headers; run header script. -- [ ] [P0] Run local verifications: - - ./eng/scripts/Build-Local.ps1 -UsePaths -VerifyNpx - - .\eng\common\spelling\Invoke-Cspell.ps1 -- [ ] [P0] dotnet build of AzureMcp.sln passes cleanly; address warnings-as-errors. -- [ ] [P1] Run Analyze-AOT-Compact.ps1 and Analyze-Code.ps1; address issues. -- [ ] [P2] Verify package versions pinned in Directory.Packages.props match standards. - - Files: Directory.Packages.props; eng/scripts/*.ps1; eng/common/spelling/*; solution-wide - -### CI and cross-platform readiness -- [ ] [P0] Investigate and fix failing CI jobs (linux_x64, osx_x64, win_x64); reproduce locally as needed. -- [ ] [P0] Ensure tests are stable (not time/date/region dependent); deflake if needed. -- [ ] [P1] Validate trimming/AOT publish on CI; ensure any publish profiles succeed. -- [ ] [P2] Add light smoke validation for each new command in CI (mock/dry-run). - - Files/areas: eng/pipelines/**; failing job logs in GitHub Actions; areas/deploy/**; areas/quota/** - -### User experience polish -- [ ] [P0] Consistent exit codes (0 success, non-zero error) and documented. -- [ ] [P0] Clear error messages with next-step guidance. -- [ ] [P1] --help output tidy with copyable examples; consistent option naming. -- [ ] [P2] Optional --verbose honoring repo logging conventions. - - Files: core/src/AzureMcp.Cli/** (command wiring/help); areas/*/*/Commands/** (messages) - -### Ownership and maintainability -- [ ] [P0] Interface-first services (IDeployService, IQuotaService) with explicit DI lifetimes. -- [ ] [P1] Reusable parsing/validation helpers with unit tests. -- [ ] [P2] Lightweight README per area (deploy, quota) describing purpose and extension points. - - Files: areas/deploy/src/AzureMcp.Deploy/Services/**; areas/quota/src/AzureMcp.Quota/**; core/src/** (DI registration) - -### Quick P0 punch list -- [ ] Fix command descriptions/names to remove legacy terms. -- [ ] Tighten validation and error messages for raw-mcp-tool-input and quota inputs. -- [ ] Ensure STJ source-gen contexts cover all (de)serialized types; remove reflection paths. -- [ ] Add/complete unit tests per command and output contract tests. -- [ ] Update docs/azmcp-commands.md, add examples, and fix typos. -- [ ] Update CHANGELOG.md for new commands/features. -- [ ] Run Build-Local verification and CSpell; fix findings. -- [ ] Address CI failures across platforms until all green. diff --git a/docs/azmcp-commands.md b/docs/azmcp-commands.md index 536811807..a8aad33af 100644 --- a/docs/azmcp-commands.md +++ b/docs/azmcp-commands.md @@ -376,6 +376,36 @@ azmcp extension azd --command "" azmcp extension azd --command "init --template todo-nodejs-mongo" ``` +### Azure Deploy Operations + +```bash +# Get the application service log for a specific azd environment +azmcp deploy app logs get --workspace-folder \ + --azd-env-name \ + [--limit ] + +# Generate a mermaid architecture diagram for the application topology follow the schema defined in [deploy-app-topology-schema.json](../areas/deploy/src/AzureMcp.Deploy/Schemas/deploy-app-topology-schema.json) +azmcp deploy architecture diagram generate --raw-mcp-tool-input + +# Get the iac generation rules for the resource types +azmcp deploy iac rules get --deployment-tool \ + --iac-type \ + --resource-types + +# Get the ci/cd pipeline guidance +azmcp deploy pipeline guidance get [--use-azd-pipeline-config ] \ + [--organization-name ] \ + [--repository-name ] \ + [--github-environment-name ] + +# Get a deployment plan for a specific project +azmcp deploy plan get --workspace-folder \ + --project-name \ + --target-app-service \ + --provisioning-tool \ + [--azd-iac-options ] +``` + ### Azure Function App Operations ```bash @@ -660,6 +690,22 @@ azmcp extension azqr --subscription \ --resource-group ``` +### Azure Quota Operations + +```bash +# Get the available regions for the resources types +azmcp quota region availability list --subscription \ + --resource-types \ + [--cognitive-service-model-name ] \ + [--cognitive-service-model-version ] \ + [--cognitive-service-deployment-sku-name ] + +# Check the usage for Azure resources type +azmcp quota usage check --subscription \ + --region \ + --resource-types +``` + ### Azure RBAC Operations ```bash @@ -946,50 +992,6 @@ azmcp workbooks update --workbook-id \ azmcp bicepschema get --resource-type \ ``` -### Quota -```bash -# Check the usage for Azure resources type -azmcp quota usage check --subscription \ - --region \ - --resource-types - -# Get the available regions for the resources types -azmcp quota region availability list --subscription \ - --resource-types \ - [--cognitive-service-model-name ] \ - [--cognitive-service-model-version ] \ - [--cognitive-service-deployment-sku-name ] -``` - -### Deploy -```bash -# Get a deployment plan for a specific project -azmcp deploy plan get --workspace-folder \ - --project-name \ - --target-app-service \ - --provisioning-tool \ - [--azd-iac-options ] - -# Get the iac generation rules for the resource types -azmcp deploy iac rules get --deployment-tool \ - --iac-type \ - --resource-types - -# Get the application service log for a specific azd environment -azmcp deploy app logs get --workspace-folder \ - --azd-env-name \ - [--limit ] - -# Get the ci/cd pipeline guidance -azmcp deploy pipeline guidance get [--use-azd-pipeline-config ] \ - [--organization-name ] \ - [--repository-name ] \ - [--github-environment-name ] - -# Generate a mermaid architecture diagram for the application topology follow the schema defined in [deploy-app-topology-schema.json](../areas/deploy/src/AzureMcp.Deploy/Schemas/deploy-app-topology-schema.json) -azmcp deploy architecture diagram generate --raw-mcp-tool-input -``` - ## Response Format All responses follow a consistent JSON format: diff --git a/docs/e2eTestPrompts.md b/docs/e2eTestPrompts.md index 9f820f6bc..faaff1de2 100644 --- a/docs/e2eTestPrompts.md +++ b/docs/e2eTestPrompts.md @@ -110,6 +110,16 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | azmcp-extension-azd | Create a To-Do list web application that uses NodeJS and MongoDB | | azmcp-extension-azd | Deploy my web application to Azure App Service | +## Azure Deploy + +| Tool Name | Test Prompt | +|:----------|:----------| +| azmcp-deploy-app-logs-get | Show me the log of the application deployed by azd | +| azmcp-deploy-architecture-diagram-generate | Generate the azure architecture diagram for this application | +| azmcp-deploy-iac-rules-get | Show me the rules to generate bicep scripts | +| azmcp-deploy-pipeline-guidance-get | How can I create a CI/CD pipeline to deploy this app to Azure? | +| azmcp-deploy-plan-get | Create a plan to deploy this application to azure | + ## Azure Function App | Tool Name | Test Prompt | @@ -225,6 +235,13 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | azmcp-extension-azqr | Provide compliance recommendations for my current Azure subscription | | azmcp-extension-azqr | Scan my Azure subscription for compliance recommendations | +## Azure Quota + +| Tool Name | Test Prompt | +|:----------|:----------| +| azmcp-quota-region-availability-list | Show me the available regions for these resource types | +| azmcp-quota-usage-check | Check usage information for in region | + ## Azure RBAC | Tool Name | Test Prompt | @@ -369,16 +386,3 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | Tool Name | Test Prompt | |:----------|:----------| | azmcp-bicepschema-get | How can I use Bicep to create an Azure OpenAI service? | - -## Deploy -| Tool Name | Test Prompt | -|:----------|:----------| -| azmcp-deploy-plan-get | Create a plan to deploy this application to azure | -| azmcp-deploy-iac-rules-get | Show me the rules to generate bicep scripts | -| azmcp-deploy-app-logs-get | Show me the log of the application deployed by azd | -| azmcp-deploy-pipeline-guidance-get | How can I create a CI/CD pipeline to deploy this app to Azure? | -| azmcp-deploy-architecture-diagram-generate | Generate the azure architecture diagram for this application | - -## Quota -| azmcp-quota-available-region-list | Show me the available regions for these resource types | -| azmcp-quota-usage-get | Check usage information for in region | From a0fa18967c7392ba9113bf9bf76308e0871ae874 Mon Sep 17 00:00:00 2001 From: qianwens Date: Mon, 18 Aug 2025 16:24:11 +0800 Subject: [PATCH 53/56] remove image --- docs/image.png | Bin 301265 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docs/image.png diff --git a/docs/image.png b/docs/image.png deleted file mode 100644 index d14d9e375050729a172cd7209208e9d5d1e8e905..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 301265 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 z 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 !LBn# zNWiR%v&;E(GG3V|t}9nJpo>}sC~U6gq)kI|`v9H}2?VO+@%G6m|(n?B5CKPoD?)=Wq** zA#Ak!C>;B(yyp*B8IJPBpFjC}TZrr1Z3vKt`_J+2qNL#THEr{Gqoc3Mhu;#N`fhgq z{rOd~Ph;v)4eXdU+FtM74DUyWhb#ADQ0S7;)Y%70ku5#&jZ9zO7QBgiI$^Req_NNc zX~vr;pTzxdnR|D;PkaSGBil%80khU)F5DpKQ!^y)yg|h0k*SVxARs6OSmAEx~e;SB{06DS3{3!eWFQb7w z;1$Q_-1sy8W^dfmcchNykFYHn6kxBwx>|W&zhkMtH!@dqP;xYYvI%w>B*4(0E3gHZ zK6|Zy>LmExq1XOB=fv;Q_fidm=a3Ku+qM_^o!1Z#38G6aiw=&nu@O48=ln^O*^jy) z@F(^1p=Se^LR`Im!n+h|0oi{z|>u?lCit=lDd$ug^nf-c_Bs* zvM4A(@mFj@*BfLXf+dq)?r3>ox81+*ly9BvOcL9YT3`2*GUEjDw>q4SU$aJOm+hsL)~q3-(Tpn6buJ|GjhgPXER>Xqf-s zzhDY`{x1;y&)?PKBc$|y{s{l|zdwR~nE(4+&|3fB#qj^lVhB#(IdaHwC}E`$us8<) O+>}$ko+D%6`+oq^!2v4( From 6c9edd0e8d206b9e9599793629bc85aa757aab25 Mon Sep 17 00:00:00 2001 From: Vinay Gera Date: Mon, 18 Aug 2025 12:43:02 -0700 Subject: [PATCH 54/56] Update Directory.Packages.props update ARM versions --- Directory.Packages.props | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0b8b424d3..7c514649e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,10 +18,10 @@ - + - - + + @@ -36,8 +36,8 @@ - - + + From 43b70f731f8912c7faf3206660a2bb46d1cbe64b Mon Sep 17 00:00:00 2001 From: Vinay Gera Date: Mon, 18 Aug 2025 13:27:28 -0700 Subject: [PATCH 55/56] Update cspell.json fix cspell json --- .vscode/cspell.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/cspell.json b/.vscode/cspell.json index a11c9c73a..bef9feac5 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -460,7 +460,7 @@ "winget", "wscript", "Xunit", - "xvfb" + "xvfb", "operationalinsights", "piechart", "timechart", From 5bfc79532cd916f24186756a457b097ab0fa5910 Mon Sep 17 00:00:00 2001 From: Vinay Gera Date: Mon, 18 Aug 2025 20:54:36 -0700 Subject: [PATCH 56/56] Update sign-and-pack.yml add YamlDotNet.dll to allow list --- eng/pipelines/templates/jobs/sign-and-pack.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/eng/pipelines/templates/jobs/sign-and-pack.yml b/eng/pipelines/templates/jobs/sign-and-pack.yml index 2a4245c1e..af9a59367 100644 --- a/eng/pipelines/templates/jobs/sign-and-pack.yml +++ b/eng/pipelines/templates/jobs/sign-and-pack.yml @@ -45,6 +45,7 @@ jobs: win-*/**/Npgsql.dll win-*/**/OpenAI.dll win-*/**/OpenTelemetry*.dll + win-*/**/YamlDotNet.dll - template: pipelines/steps/azd-cli-mac-signing.yml@azure-sdk-build-tools parameters: