diff --git a/.vscode/cspell.json b/.vscode/cspell.json index ad5e17fe2..e993cb672 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -214,16 +214,17 @@ "words": [ "1espt", "aarch", - "accesspolicy", "acaenvironment", + "accesspolicy", "ADMINPROVIDER", "agentic", + "aieval", "aisearch", "akscluster", "aksservice", "alcoop", - "AOAI", "amlfs", + "AOAI", "Apim", "appconfig", "applens", @@ -312,6 +313,7 @@ "csdevkit", "cslschema", "cvzf", + "dataagent", "datalake", "dataplane", "datasource", @@ -324,8 +326,8 @@ "devcontainers", "discoverability", "Distributedtask", - "dotnettools", "dotenv", + "dotnettools", "drawcord", "DUMPFILE", "eastasia", @@ -344,11 +346,13 @@ "filefilters", "filesystem", "filesystems", + "flexconsumption", "fnames", "francecentral", "frontendservice", "functionapp", "functionapps", + "functionspremium", "germanynorth", "gethealth", "grpcio", @@ -401,9 +405,9 @@ "mcpserver", "mcptmp", "mexicocentral", - "midsole", "Microbundle", "microsoftdocs", + "midsole", "monitoredresources", "msal", "MSRP", @@ -413,19 +417,19 @@ "mycluster", "myfilesystem", "mygroup", - "myworkbook", "mysvc", + "myworkbook", "netstandard", - "newzealandnorth", "Newtonsoft", - "Npgsql", - "nupkg", + "newzealandnorth", "norequired", "northcentralus", "northeurope", "norwayeast", "norwaywest", + "Npgsql", "npmjs", + "nupkg", "nuxt", "Occured", "odata", @@ -439,8 +443,8 @@ "payg", "paygo", "pgrep", - "piechart", "pids", + "piechart", "polandcentral", "portalsettings", "predeploy", @@ -452,7 +456,9 @@ "rainfly", "RAGRS", "RAGZRS", + "rainfly", "RediSearch", + "requesturl", "resourcegroup", "resourcegroups", "resourcehealth", @@ -516,18 +522,19 @@ "vsmarketplace", "vsts", "vuepress", + "webjobs", "westcentralus", "westeurope", "westus", "westus2", "westus3", - "winget", "WINDOWSOS", "WINDOWSPOOL", "WINDOWSVMIMAGE", + "winget", "wscript", - "xvfb", "Xunit", + "xvfb" "aieval", "requesturl", "dataagent" diff --git a/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/IResourceGroupService.cs b/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/IResourceGroupService.cs index 0cb976675..1d63bf017 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/IResourceGroupService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/IResourceGroupService.cs @@ -12,4 +12,5 @@ public interface IResourceGroupService Task> GetResourceGroups(string subscriptionId, string? tenant = null, RetryPolicyOptions? retryPolicy = null); Task GetResourceGroup(string subscriptionId, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null); Task GetResourceGroupResource(string subscriptionId, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null); + Task CreateOrUpdateResourceGroup(string subscriptionId, string resourceGroupName, string location, string? tenant = null, RetryPolicyOptions? retryPolicy = null); } diff --git a/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/ResourceGroupService.cs b/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/ResourceGroupService.cs index dce3945d6..2125b734b 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/ResourceGroupService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/ResourceGroupService.cs @@ -107,4 +107,22 @@ public async Task> GetResourceGroups(string subscription throw new Exception($"Error retrieving resource group {resourceGroupName}: {ex.Message}", ex); } } + + public async Task CreateOrUpdateResourceGroup(string subscription, string resourceGroupName, string location, string? tenant = null, RetryPolicyOptions? retryPolicy = null) + { + ValidateRequiredParameters(subscription, resourceGroupName, location); + + try + { + var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy); + var op = await subscriptionResource.GetResourceGroups() + .CreateOrUpdateAsync(WaitUntil.Completed, resourceGroupName, new ResourceGroupData(location)) + .ConfigureAwait(false); + return op.Value; + } + catch (Exception ex) + { + throw new Exception($"Error creating or updating resource group {resourceGroupName}: {ex.Message}", ex); + } + } } diff --git a/docs/azmcp-commands.md b/docs/azmcp-commands.md index 110c534d6..0de19068e 100644 --- a/docs/azmcp-commands.md +++ b/docs/azmcp-commands.md @@ -636,6 +636,15 @@ azmcp eventgrid events publish --subscription \ ### Azure Function App Operations ```bash +# Create a new Azure Function App with automatic provisioning of dependencies +azmcp functionapp create --subscription \ + --resource-group \ + --function-app \ + --location \ + [--plan-type ] \ + [--runtime ] \ + [--os ] + # Get detailed properties of function apps azmcp functionapp get --subscription \ [--resource-group ] \ diff --git a/docs/e2eTestPrompts.md b/docs/e2eTestPrompts.md index de373cca4..960fdb364 100644 --- a/docs/e2eTestPrompts.md +++ b/docs/e2eTestPrompts.md @@ -186,6 +186,11 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | Tool Name | Test Prompt | |:----------|:----------| +| azmcp_functionapp_create | Create a new Azure Function App named in | +| azmcp_functionapp_create | Create a function app with Python runtime in | +| azmcp_functionapp_create | Deploy a new function app to region | +| azmcp_functionapp_create | Set up a function app with premium hosting plan | +| azmcp_functionapp_create | Create function app with container app hosting | | azmcp_functionapp_get | Describe the function app in resource group | | azmcp_functionapp_get | Get configuration for function app | | azmcp_functionapp_get | Get function app status for | diff --git a/servers/Azure.Mcp.Server/CHANGELOG.md b/servers/Azure.Mcp.Server/CHANGELOG.md index 2ce1bdf00..7808584e3 100644 --- a/servers/Azure.Mcp.Server/CHANGELOG.md +++ b/servers/Azure.Mcp.Server/CHANGELOG.md @@ -9,6 +9,7 @@ The Azure MCP Server updates automatically by default whenever a new release com - Added support for Azure Developer CLI (azd) MCP tools when azd CLI is installed locally - [[#566](https://github.com/microsoft/mcp/issues/566)] - Adds support to proxy MCP capabilities when child servers leverage sampling or elicitation. [[#581](https://github.com/microsoft/mcp/pull/581)] - Added support for publishing custom events to Event Grid topics via the command `azmcp_eventgrid_events_publish`. Supports EventGrid, CloudEvents, and custom schemas with structured event data delivery for event-driven architectures. [[#514](https://github.com/microsoft/mcp/pull/514)] +- Added support for creating Azure Function Apps via the command `azmcp_functionapp_create`. Supports multiple hosting plans (Consumption, Flex Consumption, Premium, App Service, Container App) with automatic provisioning of dependencies and runtime defaults. [[#604](https://github.com/microsoft/mcp/pull/604)] ### Breaking Changes diff --git a/tools/Azure.Mcp.Tools.FunctionApp/src/Azure.Mcp.Tools.FunctionApp.csproj b/tools/Azure.Mcp.Tools.FunctionApp/src/Azure.Mcp.Tools.FunctionApp.csproj index 9f48e4f66..5f226b163 100644 --- a/tools/Azure.Mcp.Tools.FunctionApp/src/Azure.Mcp.Tools.FunctionApp.csproj +++ b/tools/Azure.Mcp.Tools.FunctionApp/src/Azure.Mcp.Tools.FunctionApp.csproj @@ -12,6 +12,8 @@ + + diff --git a/tools/Azure.Mcp.Tools.FunctionApp/src/Commands/FunctionApp/FunctionAppCreateCommand.cs b/tools/Azure.Mcp.Tools.FunctionApp/src/Commands/FunctionApp/FunctionAppCreateCommand.cs new file mode 100644 index 000000000..3ffacf79f --- /dev/null +++ b/tools/Azure.Mcp.Tools.FunctionApp/src/Commands/FunctionApp/FunctionAppCreateCommand.cs @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Tools.FunctionApp.Models; +using Azure.Mcp.Tools.FunctionApp.Options; +using Azure.Mcp.Tools.FunctionApp.Options.FunctionApp; +using Azure.Mcp.Tools.FunctionApp.Services; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Tools.FunctionApp.Commands.FunctionApp; + +public sealed class FunctionAppCreateCommand(ILogger logger) + : BaseFunctionAppCommand +{ + private const string CommandTitle = "Create Azure Function App"; + private readonly ILogger _logger = logger; + + private readonly Option _functionAppNameOption = FunctionAppOptionDefinitions.FunctionApp; + private readonly Option _locationOption = FunctionAppOptionDefinitions.Location; + private readonly Option _appServicePlanOption = FunctionAppOptionDefinitions.AppServicePlan; + private readonly Option _planTypeOption = FunctionAppOptionDefinitions.PlanType; + private readonly Option _planSkuOption = FunctionAppOptionDefinitions.PlanSku; + private readonly Option _runtimeOption = FunctionAppOptionDefinitions.Runtime; + private readonly Option _runtimeVersionOption = FunctionAppOptionDefinitions.RuntimeVersion; + private readonly Option _osOption = FunctionAppOptionDefinitions.OperatingSystem; + private readonly Option _storageAccountOption = FunctionAppOptionDefinitions.StorageAccount; + private readonly Option _containerAppsEnvironmentOption = FunctionAppOptionDefinitions.ContainerAppsEnvironment; + + public override string Name => "create"; + + public override string Description => + """ + Create a new Azure Function App in the specified resource group and region. + Automatically provisions dependencies when omitted (App Service plan OR Container App managed environment + Container App, and a Storage account) and applies sensible runtime & SKU defaults. + + Required options: + - subscription: Target Azure subscription (ID or name) + - resource-group: Resource group (created if missing) + - function-app: Globally unique Function App name + - location: Azure region (e.g. eastus) + + Optional options: + - app-service-plan: Use an existing App Service plan; if omitted one is created when hosting on App Service (non-container). + - plan-type: Hosting kind to create when a plan is needed (consumption|flex|premium|appservice|containerapp). Default: consumption. + * consumption -> Y1 (Dynamic) + * flex / flexconsumption -> FC1 (FlexConsumption, Linux only) + * premium / functionspremium -> EP1 (Elastic Premium) + * appservice -> B1 (Basic) unless overridden by --plan-sku + * containerapp -> Creates a Container App instead of an App Service plan/site (no plan created). Container App will reuse the function-app name. + - plan-sku: Explicit App Service plan SKU (e.g. B1, S1, P1v3). Overrides --plan-type SKU selection (ignored for containerapp). + - runtime: FUNCTIONS_WORKER_RUNTIME (dotnet|dotnet-isolated|node|python|java|powershell). Default: dotnet. + - runtime-version: Specific runtime version; if omitted a default per runtime is applied (see defaults below). + - os: windows|linux. Default: windows unless runtime/plan requires linux (python, flex consumption, containerapp). Overridden to linux automatically when required. Python & flex consumption do not support Windows. + + Automatic resources & defaults: + - Storage account: Always created (Standard_LRS, HTTPS only, blob public access disabled). Name pattern: [random6]. Connection string injected as AzureWebJobsStorage. + - App Service plan: Auto-created when not provided (name: -plan) unless containerapp hosting. + - Container App: If containerapp hosting selected, a managed environment and container app are created using the function-app name and an official Azure Functions image for the runtime. + - Linux vs Windows: Linux automatically enforced for python and flex consumption. Other runtimes default to Windows unless plan-type dictates Linux (flex) or runtime is python. + - Explicit --os overrides default when compatible; incompatible combinations cause validation errors (e.g. --os windows with python or flex consumption). + - Runtime version defaults (LinuxFxVersion when Linux): + * python -> 3.12 + * node -> 22 + * dotnet -> 8.0 + * java -> 21.0 + * powershell -> 7.4 + - WEBSITE_NODE_DEFAULT_VERSION: Set to ~ for Windows Node apps when a version is supplied. + - FUNCTIONS_EXTENSION_VERSION: Always ~4. + + Behavior notes: + - Providing --plan-sku with --plan-type is allowed; SKU wins. + - --container-app path skips App Service plan & site creation and provisions a Container App instead. + - Invalid combination examples: specifying app-service-plan with plan-type containerapp. + + Returns: functionApp object (name, resourceGroup, location, plan, state, defaultHostName, tags) + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() + { + Destructive = true, + Idempotent = false, + OpenWorld = false, + ReadOnly = false, + LocalRequired = false, + Secret = false + }; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(OptionDefinitions.Common.ResourceGroup.AsRequired()); + command.Options.Add(_functionAppNameOption); + command.Options.Add(_locationOption); + command.Options.Add(_appServicePlanOption); + command.Options.Add(_planTypeOption); + command.Options.Add(_planSkuOption); + command.Options.Add(_runtimeOption); + command.Options.Add(_runtimeVersionOption); + command.Options.Add(_osOption); + command.Options.Add(_storageAccountOption); + command.Options.Add(_containerAppsEnvironmentOption); + } + + protected override FunctionAppCreateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.ResourceGroup ??= parseResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup.Name); + options.FunctionAppName = parseResult.GetValueOrDefault(_functionAppNameOption.Name); + options.Location = parseResult.GetValueOrDefault(_locationOption.Name); + options.AppServicePlan = parseResult.GetValueOrDefault(_appServicePlanOption.Name); + options.PlanType = parseResult.GetValueOrDefault(_planTypeOption.Name); + options.PlanSku = parseResult.GetValueOrDefault(_planSkuOption.Name); + options.Runtime = parseResult.GetValueOrDefault(_runtimeOption.Name) ?? "dotnet"; + options.RuntimeVersion = parseResult.GetValueOrDefault(_runtimeVersionOption.Name); + options.OperatingSystem = parseResult.GetValueOrDefault(_osOption.Name); + options.StorageAccount = parseResult.GetValueOrDefault(_storageAccountOption.Name); + options.ContainerAppsEnvironment = parseResult.GetValueOrDefault(_containerAppsEnvironmentOption.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + var options = BindOptions(parseResult); + + try + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + return context.Response; + + if (!string.IsNullOrWhiteSpace(options.FunctionAppName)) + { + var len = options.FunctionAppName.Length; + if (len < 2 || len > 43) + { + context.Response.Status = HttpStatusCode.BadRequest; + context.Response.Message = "function-app name must be between 2 and 43 characters."; + return context.Response; + } + } + + if (!string.IsNullOrWhiteSpace(options.AppServicePlan) && string.Equals(options.PlanType, "containerapp", StringComparison.OrdinalIgnoreCase)) + { + context.Response.Status = HttpStatusCode.BadRequest; + context.Response.Message = "--app-service-plan cannot be combined with --plan-type containerapp."; + return context.Response; + } + + var service = context.GetService(); + var result = await service.CreateFunctionApp( + options.Subscription!, + options.ResourceGroup!, + options.FunctionAppName!, + options.Location!, + options.AppServicePlan, + options.PlanType, + options.PlanSku, + options.Runtime ?? "dotnet", + options.RuntimeVersion, + options.OperatingSystem, + options.StorageAccount, + options.ContainerAppsEnvironment, + options.Tenant, + options.RetryPolicy); + + context.Response.Results = ResponseResult.Create( + new FunctionAppCreateCommandResult(result), + FunctionAppJsonContext.Default.FunctionAppCreateCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error creating function app. Subscription: {Subscription}, ResourceGroup: {ResourceGroup}, FunctionApp: {FunctionApp}, Options: {@Options}", + options.Subscription, options.ResourceGroup, options.FunctionAppName, options); + HandleException(context, ex); + } + + return context.Response; + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Conflict => + "Function App name already exists or conflict in resource group. Choose a different name or check plan settings.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.NotFound => + "Resource group or plan not found. Verify the resource group and plan exist and you have access.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden => + $"Authorization failed accessing the Function App. Details: {reqEx.Message}", + RequestFailedException reqEx => reqEx.Message, + _ => base.GetErrorMessage(ex) + }; + + internal record FunctionAppCreateCommandResult(FunctionAppInfo FunctionApp); +} diff --git a/tools/Azure.Mcp.Tools.FunctionApp/src/Commands/FunctionAppJsonContext.cs b/tools/Azure.Mcp.Tools.FunctionApp/src/Commands/FunctionAppJsonContext.cs index 7a23af467..44974de7e 100644 --- a/tools/Azure.Mcp.Tools.FunctionApp/src/Commands/FunctionAppJsonContext.cs +++ b/tools/Azure.Mcp.Tools.FunctionApp/src/Commands/FunctionAppJsonContext.cs @@ -8,6 +8,7 @@ namespace Azure.Mcp.Tools.FunctionApp.Commands; [JsonSerializable(typeof(FunctionAppGetCommand.FunctionAppGetCommandResult))] +[JsonSerializable(typeof(FunctionAppCreateCommand.FunctionAppCreateCommandResult))] [JsonSerializable(typeof(FunctionAppInfo))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] internal partial class FunctionAppJsonContext : JsonSerializerContext; diff --git a/tools/Azure.Mcp.Tools.FunctionApp/src/FunctionAppSetup.cs b/tools/Azure.Mcp.Tools.FunctionApp/src/FunctionAppSetup.cs index 39ec5da6f..5cc990156 100644 --- a/tools/Azure.Mcp.Tools.FunctionApp/src/FunctionAppSetup.cs +++ b/tools/Azure.Mcp.Tools.FunctionApp/src/FunctionAppSetup.cs @@ -18,6 +18,8 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); + + services.AddSingleton(); } public CommandGroup RegisterCommands(IServiceProvider serviceProvider) @@ -27,6 +29,9 @@ public CommandGroup RegisterCommands(IServiceProvider serviceProvider) var getCommand = serviceProvider.GetRequiredService(); functionApp.AddCommand(getCommand.Name, getCommand); + var createCommand = serviceProvider.GetRequiredService(); + functionApp.AddCommand(createCommand.Name, createCommand); + return functionApp; } } diff --git a/tools/Azure.Mcp.Tools.FunctionApp/src/Models/FunctionAppInfo.cs b/tools/Azure.Mcp.Tools.FunctionApp/src/Models/FunctionAppInfo.cs index 742ea8c66..f09cb6102 100644 --- a/tools/Azure.Mcp.Tools.FunctionApp/src/Models/FunctionAppInfo.cs +++ b/tools/Azure.Mcp.Tools.FunctionApp/src/Models/FunctionAppInfo.cs @@ -12,5 +12,6 @@ public record FunctionAppInfo( [property: JsonPropertyName("appServicePlanName")] string? AppServicePlanName, [property: JsonPropertyName("status")] string? Status, [property: JsonPropertyName("defaultHostName")] string? DefaultHostName, + [property: JsonPropertyName("operatingSystem")] string? OperatingSystem, [property: JsonPropertyName("tags")] IDictionary? Tags ); diff --git a/tools/Azure.Mcp.Tools.FunctionApp/src/Options/FunctionApp/FunctionAppCreateOptions.cs b/tools/Azure.Mcp.Tools.FunctionApp/src/Options/FunctionApp/FunctionAppCreateOptions.cs new file mode 100644 index 000000000..093ff71a7 --- /dev/null +++ b/tools/Azure.Mcp.Tools.FunctionApp/src/Options/FunctionApp/FunctionAppCreateOptions.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.FunctionApp.Options.FunctionApp; + +public class FunctionAppCreateOptions : BaseFunctionAppOptions +{ + [JsonPropertyName(FunctionAppOptionDefinitions.LocationName)] + public string? Location { get; set; } + + [JsonPropertyName(FunctionAppOptionDefinitions.AppServicePlanName)] + public string? AppServicePlan { get; set; } + + [JsonPropertyName(FunctionAppOptionDefinitions.PlanTypeName)] + public string? PlanType { get; set; } + + [JsonPropertyName(FunctionAppOptionDefinitions.PlanSkuName)] + public string? PlanSku { get; set; } + + [JsonPropertyName(FunctionAppOptionDefinitions.RuntimeName)] + public string? Runtime { get; set; } + + [JsonPropertyName(FunctionAppOptionDefinitions.RuntimeVersionName)] + public string? RuntimeVersion { get; set; } + + [JsonPropertyName(FunctionAppOptionDefinitions.OperatingSystemName)] + public string? OperatingSystem { get; set; } + + [JsonPropertyName(FunctionAppOptionDefinitions.StorageAccountName)] + public string? StorageAccount { get; set; } + + [JsonPropertyName(FunctionAppOptionDefinitions.ContainerAppsEnvironmentName)] + public string? ContainerAppsEnvironment { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.FunctionApp/src/Options/FunctionAppOptionDefinitions.cs b/tools/Azure.Mcp.Tools.FunctionApp/src/Options/FunctionAppOptionDefinitions.cs index 3bc683df4..222bb8f78 100644 --- a/tools/Azure.Mcp.Tools.FunctionApp/src/Options/FunctionAppOptionDefinitions.cs +++ b/tools/Azure.Mcp.Tools.FunctionApp/src/Options/FunctionAppOptionDefinitions.cs @@ -6,6 +6,15 @@ namespace Azure.Mcp.Tools.FunctionApp.Options; public static class FunctionAppOptionDefinitions { public const string FunctionAppName = "function-app"; + public const string LocationName = "location"; + public const string AppServicePlanName = "app-service-plan"; + public const string PlanTypeName = "plan-type"; + public const string PlanSkuName = "plan-sku"; + public const string RuntimeName = "runtime"; + public const string RuntimeVersionName = "runtime-version"; + public const string OperatingSystemName = "os"; + public const string StorageAccountName = "storage-account"; + public const string ContainerAppsEnvironmentName = "container-apps-environment"; public static readonly Option FunctionApp = new( $"--{FunctionAppName}" @@ -14,4 +23,76 @@ public static class FunctionAppOptionDefinitions Description = "The name of the Function App.", Required = false }; + + public static readonly Option Location = new( + $"--{LocationName}" + ) + { + Description = "The Azure region for the Function App (e.g., eastus, westus2).", + Required = true + }; + + public static readonly Option AppServicePlan = new( + $"--{AppServicePlanName}" + ) + { + Description = "The App Service plan name to use. If not supplied, a Consumption plan will be created automatically.", + Required = false + }; + + public static readonly Option PlanType = new( + $"--{PlanTypeName}" + ) + { + Description = "The App Service plan type when creating a plan automatically. Values: consumption, flex, premium. Defaults to consumption.", + Required = false + }; + + public static readonly Option PlanSku = new( + $"--{PlanSkuName}" + ) + { + Description = "The explicit App Service plan SKU (e.g., B1, S1, P1v3). Mutually exclusive with --plan-type. If provided and --app-service-plan omitted a dedicated plan using this SKU is created.", + Required = false + }; + + public static readonly Option Runtime = new( + $"--{RuntimeName}" + ) + { + Description = "Function runtime worker. Examples: dotnet, node, python. Defaults to dotnet.", + Required = false + }; + + public static readonly Option RuntimeVersion = new( + $"--{RuntimeVersionName}" + ) + { + Description = "Runtime version for the selected worker (e.g., node: 22, 20; python: 3.12). If omitted, a sensible default is used.", + Required = false + }; + + public static readonly Option OperatingSystem = new( + $"--{OperatingSystemName}" + ) + { + Description = "Target operating system (windows|linux). Defaults to windows except when runtime/plan requires Linux (python, flex consumption, containerapp). Python and flex consumption are Linux only.", + Required = false + }; + + public static readonly Option StorageAccount = new( + $"--{StorageAccountName}" + ) + { + Description = "The name of the Storage Account to use or create.", + Required = false + }; + + public static readonly Option ContainerAppsEnvironment = new( + $"--{ContainerAppsEnvironmentName}" + ) + { + Description = "The name of the Container Apps environment to use or create.", + Required = false + }; } diff --git a/tools/Azure.Mcp.Tools.FunctionApp/src/Services/FunctionAppService.cs b/tools/Azure.Mcp.Tools.FunctionApp/src/Services/FunctionAppService.cs index 2b2178fcb..8d3308b2e 100644 --- a/tools/Azure.Mcp.Tools.FunctionApp/src/Services/FunctionAppService.cs +++ b/tools/Azure.Mcp.Tools.FunctionApp/src/Services/FunctionAppService.cs @@ -6,7 +6,13 @@ using Azure.Mcp.Core.Services.Azure.Tenant; using Azure.Mcp.Core.Services.Caching; using Azure.Mcp.Tools.FunctionApp.Models; +using Azure.ResourceManager.AppContainers; +using Azure.ResourceManager.AppContainers.Models; using Azure.ResourceManager.AppService; +using Azure.ResourceManager.AppService.Models; +using Azure.ResourceManager.Resources; +using Azure.ResourceManager.Storage; +using Azure.ResourceManager.Storage.Models; namespace Azure.Mcp.Tools.FunctionApp.Services; @@ -19,7 +25,94 @@ public sealed class FunctionAppService( private readonly ICacheService _cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); private const string CacheGroup = "functionapp"; - private static readonly TimeSpan s_cacheDuration = TimeSpan.FromHours(1); + private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1); + + private static readonly HashSet SupportedRuntimes = new() + { + "dotnet", "dotnet-isolated", "node", "python", "java", "powershell", "custom" + }; + + private static class ContainerAppsDefaults + { + public const double CpuCores = 0.25; + public const string Memory = "0.5Gi"; + public const int IngressPort = 80; + public const bool ExternalIngress = true; + } + + private static class FlexConsumptionDefaults + { + public const int InstanceMemoryMB = 2048; + public const int MaximumInstanceCount = 100; + } + + private static string? GetDefaultRuntimeVersion(string runtime) => runtime switch + { + "python" => "3.12", + "node" => "22", + "dotnet" => "8.0", + "dotnet-isolated" => "8.0", + "java" => "17", + "powershell" => "7.4", + _ => null + }; + + internal static string GetContainerImage(string runtime) => runtime switch + { + "dotnet" => "mcr.microsoft.com/azure-functions/dotnet:4", + "dotnet-isolated" => "mcr.microsoft.com/azure-functions/dotnet-isolated:4", + "node" => "mcr.microsoft.com/azure-functions/node:4", + "python" => "mcr.microsoft.com/azure-functions/python:4", + "java" => "mcr.microsoft.com/azure-functions/java:4", + "powershell" => "mcr.microsoft.com/azure-functions/powershell:4", + _ => "mcr.microsoft.com/azure-functions/dotnet-isolated:4" + }; + + private static AppServiceSkuDescription GetDefaultSku(HostingKind hostingKind) => hostingKind switch + { + HostingKind.FlexConsumption => new AppServiceSkuDescription { Name = "FC1", Tier = "FlexConsumption" }, + HostingKind.Premium => new AppServiceSkuDescription { Name = "EP1", Tier = "ElasticPremium" }, + HostingKind.Consumption => new AppServiceSkuDescription { Name = "Y1", Tier = "Dynamic" }, + HostingKind.AppService => new AppServiceSkuDescription { Name = "B1", Tier = "Basic" }, + _ => new AppServiceSkuDescription { Name = "Y1", Tier = "Dynamic" } + }; + + internal readonly record struct NormalizedInputs( + string Runtime, + string? RuntimeVersion, + string? PlanType, + string? PlanSku, + string? OperatingSystem, + string? StorageAccountName, + string? ContainerAppsEnvironmentName); + + internal static NormalizedInputs ValidateAndNormalizeInputs( + string subscription, + string resourceGroup, + string functionAppName, + string location, + string? runtime, + string? runtimeVersion, + string? hostingKind, + string? sku, + string? os, + string? storageAccountName, + string? containerAppsEnvironmentName) + { + ValidateRequiredParameters(subscription, resourceGroup, functionAppName, location); + + var inputs = new NormalizedInputs( + string.IsNullOrWhiteSpace(runtime) ? "dotnet" : runtime.Trim().ToLowerInvariant(), + string.IsNullOrWhiteSpace(runtimeVersion) ? null : runtimeVersion.Trim(), + string.IsNullOrWhiteSpace(hostingKind) ? null : hostingKind.Trim().ToLowerInvariant(), + string.IsNullOrWhiteSpace(sku) ? null : sku.Trim(), + string.IsNullOrWhiteSpace(os) ? null : os.Trim().ToLowerInvariant(), + string.IsNullOrWhiteSpace(storageAccountName) ? null : storageAccountName.Trim(), + string.IsNullOrWhiteSpace(containerAppsEnvironmentName) ? null : containerAppsEnvironmentName.Trim()); + + ValidateParameterCombinations(inputs); + return inputs; + } public async Task?> GetFunctionApp( string subscription, @@ -29,94 +122,603 @@ public sealed class FunctionAppService( RetryPolicyOptions? retryPolicy = null) { ValidateRequiredParameters(subscription); + var cacheKey = string.IsNullOrEmpty(tenant) ? subscription : $"{subscription}_{tenant}"; + var cachedResults = await _cacheService.GetAsync>(CacheGroup, cacheKey, CacheDuration); + if (cachedResults != null) + { + return cachedResults; + } var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy); var functionApps = new List(); - if (string.IsNullOrEmpty(functionAppName)) + + await foreach (var site in subscriptionResource.GetWebSitesAsync()) { - var cacheKey = string.IsNullOrEmpty(tenant) ? subscription : $"{subscription}_{tenant}"; - cacheKey = string.IsNullOrEmpty(resourceGroup) ? cacheKey : $"{cacheKey}_{resourceGroup}"; + if (site?.Data is { } d && IsFunctionApp(d)) + functionApps.Add(ConvertToFunctionAppModel(site)); + } + await _cacheService.SetAsync(CacheGroup, cacheKey, functionApps, CacheDuration); + + return functionApps; + } - var cachedResults = await _cacheService.GetAsync>(CacheGroup, cacheKey, s_cacheDuration); - if (cachedResults != null) + private static async Task RetrieveAndAddFunctionApp(AsyncPageable sites, List functionApps) + { + await foreach (var site in sites) + { + if (site?.Data is { } data && IsFunctionApp(data)) { - return cachedResults; + functionApps.Add(ConvertToFunctionAppModel(site)); } + } + } + + public async Task CreateFunctionApp( + string subscription, + string resourceGroup, + string functionAppName, + string location, + string? planName = null, + string? hostingKind = null, + string? sku = null, + string? runtime = null, + string? runtimeVersion = null, + string? os = null, + string? storageAccountName = null, + string? containerAppsEnvironmentName = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null) + { + var inputs = ValidateAndNormalizeInputs( + subscription, resourceGroup, functionAppName, location, + runtime, runtimeVersion, hostingKind, sku, os, + storageAccountName, containerAppsEnvironmentName); + var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy); + + ResourceGroupResource rg; + var resourceGroupResponse = await subscriptionResource.GetResourceGroupAsync(resourceGroup); + if (resourceGroupResponse?.Value == null) + { + var rgData = new ResourceGroupData(location); + var rgOp = await subscriptionResource.GetResourceGroups().CreateOrUpdateAsync(WaitUntil.Completed, resourceGroup, rgData); + rg = rgOp.Value; + } + else + { + rg = resourceGroupResponse.Value; + } + + var options = BuildCreateOptions(inputs); + + return options.HostingKind == HostingKind.ContainerApp + ? await ContainerAppsStrategy.CreateFunctionAppAsync(subscriptionResource, rg, functionAppName, location, options, inputs.StorageAccountName, inputs.ContainerAppsEnvironmentName) + : await AppServiceStrategy.CreateFunctionAppAsync(subscriptionResource, rg, functionAppName, location, planName, options, inputs.StorageAccountName); + } + + internal static void ValidateParameterCombinations(NormalizedInputs inputs) + { + var hostingKind = ParseHostingKind(inputs.PlanType); + + if (inputs.OperatingSystem is not null && inputs.OperatingSystem != "windows" && inputs.OperatingSystem != "linux") + throw new ArgumentException("Operating system must be either 'windows' or 'linux'."); - try + if (inputs.StorageAccountName is not null) + { + if (inputs.StorageAccountName.Length < 3 || inputs.StorageAccountName.Length > 24) + throw new ArgumentException("Storage account name must be between 3 and 24 characters long."); + if (!inputs.StorageAccountName.All(c => char.IsLetterOrDigit(c) && (char.IsDigit(c) || char.IsLower(c)))) + throw new ArgumentException("Storage account name must contain only lowercase letters and numbers."); + } + + if (inputs.ContainerAppsEnvironmentName is not null && hostingKind != HostingKind.ContainerApp) + throw new InvalidOperationException("Container Apps environment name can only be specified when using Container Apps hosting."); + + if (hostingKind == HostingKind.ContainerApp && inputs.PlanSku is not null) + throw new InvalidOperationException("Plan SKU cannot be specified for Container Apps hosting."); + + if (!SupportedRuntimes.Contains(inputs.Runtime)) + throw new ArgumentException($"Runtime '{inputs.Runtime}' is not supported. Supported runtimes: {string.Join(", ", SupportedRuntimes)}."); + + if (inputs.Runtime == "python" && inputs.OperatingSystem == "windows") + throw new InvalidOperationException("Python runtime requires Linux operating system."); + + if (hostingKind == HostingKind.FlexConsumption && inputs.Runtime == "dotnet" && inputs.RuntimeVersion is not null) + { + throw new InvalidOperationException("Flex Consumption with .NET runtime automatically uses dotnet-isolated. Specify runtime as 'dotnet-isolated' instead."); + } + } + + internal static CreateOptions BuildCreateOptions(NormalizedInputs inputs) + { + var hostingKind = ParseHostingKind(inputs.PlanType); + var selectedRuntime = hostingKind == HostingKind.FlexConsumption && inputs.Runtime == "dotnet" + ? "dotnet-isolated" + : inputs.Runtime; + var selectedRuntimeVersion = inputs.RuntimeVersion ?? GetDefaultRuntimeVersion(selectedRuntime); + var (requiresLinux, normalizedOs) = ResolveOs(selectedRuntime, hostingKind, inputs.OperatingSystem); + return new CreateOptions(selectedRuntime, selectedRuntimeVersion, hostingKind, requiresLinux, inputs.PlanSku, normalizedOs); + } + + internal static HostingKind ParseHostingKind(string? planType) + { + return planType switch + { + "flex" or "flexconsumption" => HostingKind.FlexConsumption, + "premium" or "functionspremium" => HostingKind.Premium, + "appservice" => HostingKind.AppService, + "containerapp" or "containerapps" => HostingKind.ContainerApp, + _ => HostingKind.Consumption + }; + } + + internal static async Task CreatePlan(ResourceGroupResource rg, string planName, string location, CreateOptions options) + { + var sku = !string.IsNullOrWhiteSpace(options.ExplicitSku) + ? new AppServiceSkuDescription { Name = options.ExplicitSku!.Trim(), Tier = InferTier(options.ExplicitSku!) } + : GetDefaultSku(options.HostingKind); + + var data = new AppServicePlanData(location) { Sku = sku, IsReserved = options.RequiresLinux }; + var op = await rg.GetAppServicePlans().CreateOrUpdateAsync(WaitUntil.Completed, planName, data); + return op.Value; + } + + internal static void ValidateExistingPlan(AppServicePlanResource plan, string planName, CreateOptions options) + { + if (options.RequiresLinux && plan.Data.IsReserved != true) + throw new InvalidOperationException($"App Service plan '{planName}' must be Linux for runtime '{options.Runtime}'."); + + if (options.HostingKind == HostingKind.FlexConsumption && !IsFlexConsumption(plan.Data)) + throw new InvalidOperationException($"App Service plan '{planName}' is not a Flex Consumption plan."); + if (options.HostingKind == HostingKind.Premium && !string.Equals(plan.Data.Sku?.Tier, "ElasticPremium", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"App Service plan '{planName}' is not an Elastic Premium plan."); + } + + internal static SiteConfigProperties? BuildSiteConfig(bool isLinux, CreateOptions options) + { + if (isLinux) + return CreateLinuxSiteConfig(options.Runtime, options.RuntimeVersion); + return options.Runtime == "powershell" ? CreateWindowsPowerShellSiteConfig(options.RuntimeVersion) : null; + } + + internal static string BuildKind(bool isLinux) => isLinux ? "functionapp,linux" : "functionapp"; + + internal static async Task EnsureAppServicePlan(ResourceGroupResource rg, string? planName, string functionAppName, string location, CreateOptions options) + { + var effectivePlanName = planName ?? $"{functionAppName}-plan"; + var plans = rg.GetAppServicePlans(); + + if (await plans.ExistsAsync(effectivePlanName)) + { + var existing = await plans.GetAsync(effectivePlanName); + ValidateExistingPlan(existing, effectivePlanName, options); + return existing; + } + + return await CreatePlan(rg, effectivePlanName, location, options); + } + + internal static async Task CreateAppServiceSiteAsync(ResourceGroupResource rg, string functionAppName, string location, AppServicePlanResource plan, CreateOptions options, string storageConnection) + { + var isLinux = plan.Data.IsReserved == true; + var data = new WebSiteData(location) + { + Kind = BuildKind(isLinux), + AppServicePlanId = plan.Id, + SiteConfig = BuildSiteConfig(isLinux, options) + }; + if (options.HostingKind == HostingKind.FlexConsumption) + { + if (data.SiteConfig is not null) + { + data.SiteConfig.LinuxFxVersion = null; + } + data.FunctionAppConfig = new FunctionAppConfig { - if (string.IsNullOrEmpty(resourceGroup)) + Runtime = new FunctionAppRuntime { - await RetrieveAndAddFunctionApp(subscriptionResource.GetWebSitesAsync(), functionApps); - } - else + Name = MapToFunctionAppRuntimeName(options.Runtime), + Version = NormalizeRuntimeVersionForConfig(options.Runtime, options.RuntimeVersion) + }, + DeploymentStorage = BuildDeploymentStorage(storageConnection), + ScaleAndConcurrency = new FunctionAppScaleAndConcurrency { - var resourceGroupResource = await subscriptionResource.GetResourceGroupAsync(resourceGroup); - if (!resourceGroupResource.HasValue) - { - throw new Exception($"Resource group '{resourceGroup}' not found in subscription '{subscription}'"); - } - - await RetrieveAndAddFunctionApp(resourceGroupResource.Value.GetWebSites().GetAllAsync(), functionApps); + InstanceMemoryMB = FlexConsumptionDefaults.InstanceMemoryMB, + MaximumInstanceCount = FlexConsumptionDefaults.MaximumInstanceCount } + }; + } + var op = await rg.GetWebSites().CreateOrUpdateAsync(WaitUntil.Completed, functionAppName, data); + return op.Value; + } - await _cacheService.SetAsync(CacheGroup, cacheKey, functionApps, s_cacheDuration); - } - catch (Exception ex) - { - throw new Exception($"Error listing Function Apps: {ex.Message}", ex); - } + + internal static async Task ApplyAppSettings(WebSiteResource site, CreateOptions options, string storageConnectionString) + { + var appSettings = BuildAppSettings(options.Runtime, options.RuntimeVersion, options.RequiresLinux, storageConnectionString, includeWorkerRuntime: options.HostingKind != HostingKind.FlexConsumption); + if (options.HostingKind == HostingKind.FlexConsumption) + SanitizeAppSettingsForFlexConsumption(appSettings); + await site.UpdateApplicationSettingsAsync(appSettings); + } + + internal static bool IsFunctionApp(WebSiteData siteData) + { + return siteData.Kind?.Contains("functionapp", StringComparison.OrdinalIgnoreCase) == true; + } + + internal static FunctionAppInfo ConvertToFunctionAppModel(WebSiteResource siteResource) + { + var data = siteResource.Data; + + var os = data.Kind?.Contains("linux", StringComparison.OrdinalIgnoreCase) == true ? "linux" : "windows"; + return new FunctionAppInfo( + data.Name, + siteResource.Id.ResourceGroupName, + data.Location.ToString(), + data.AppServicePlanId.Name, + data.State, + data.DefaultHostName, + os, + data.Tags?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + ); + } + + internal static string CreateStorageAccountName(string functionAppName) + { + var baseName = new string(functionAppName.ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray()); + if (string.IsNullOrEmpty(baseName)) + { + baseName = "func"; + } + var trimmed = baseName.Length > 18 ? baseName.Substring(0, 18) : baseName; + var suffix = Guid.NewGuid().ToString("N").Substring(0, 6); + return $"{trimmed}{suffix}"; + } + + internal static string BuildConnectionString(string accountName, string key) + { + return $"DefaultEndpointsProtocol=https;AccountName={accountName};AccountKey={key};EndpointSuffix=core.windows.net"; + } + + internal static async Task EnsureStorageForFunctionApp(SubscriptionResource subscription, ResourceGroupResource rg, string functionAppName, string location, string? storageAccountName = null) + { + var accountName = storageAccountName ?? CreateStorageAccountName(functionAppName); + var storageAccounts = rg.GetStorageAccounts(); + + StorageAccountResource storage; + if (await storageAccounts.ExistsAsync(accountName)) + { + storage = await storageAccounts.GetAsync(accountName); } else { - ValidateRequiredParameters(functionAppName, resourceGroup); + var createOptions = CreateStorageAccountOptions(location); + var op = await storageAccounts.CreateOrUpdateAsync(Azure.WaitUntil.Completed, accountName, createOptions); + storage = op.Value; + } + + var keys = new List(); + await foreach (var key in storage.GetKeysAsync()) + { + keys.Add(key); + } + var primary = keys.FirstOrDefault() ?? throw new InvalidOperationException($"No keys found for storage account '{accountName}'"); + return BuildConnectionString(accountName, primary.Value); + } + + internal static StorageAccountCreateOrUpdateContent CreateStorageAccountOptions(string location) + { + return new StorageAccountCreateOrUpdateContent( + new StorageSku(StorageSkuName.StandardLrs), + StorageKind.StorageV2, + location) + { + AccessTier = StorageAccountAccessTier.Hot, + EnableHttpsTrafficOnly = true, + AllowBlobPublicAccess = false, + IsHnsEnabled = false + }; + } - var cacheKey = string.IsNullOrEmpty(tenant) - ? $"{subscription}_{resourceGroup}_{functionAppName}" - : $"{subscription}_{tenant}_{resourceGroup}_{functionAppName}"; + internal static SiteConfigProperties? CreateLinuxSiteConfig(string runtime, string? runtimeVersion) + { + var config = new SiteConfigProperties(); + var version = string.IsNullOrWhiteSpace(runtimeVersion) ? GetDefaultRuntimeVersion(runtime) : runtimeVersion; + if (runtime == "java" && version != null && version.EndsWith(".0", StringComparison.Ordinal)) + version = version.Substring(0, version.Length - 2); + config.LinuxFxVersion = runtime switch + { + "python" => $"Python|{version}", + "node" => $"Node|{version}", + "dotnet" => $"DOTNET|{version}", + "dotnet-isolated" => $"DOTNET-ISOLATED|{version}", + "java" => $"Java|{version}", + "powershell" => $"PowerShell|{version}", + _ => config.LinuxFxVersion + }; + return config; + } - var cachedResults = await _cacheService.GetAsync>(CacheGroup, cacheKey, s_cacheDuration); - if (cachedResults != null) + internal static FunctionAppRuntimeName MapToFunctionAppRuntimeName(string runtime) => runtime switch + { + "dotnet-isolated" => FunctionAppRuntimeName.DotnetIsolated, + "node" => FunctionAppRuntimeName.Node, + "java" => FunctionAppRuntimeName.Java, + "powershell" => FunctionAppRuntimeName.Powershell, + "python" => FunctionAppRuntimeName.Python, + "dotnet" => FunctionAppRuntimeName.DotnetIsolated, + _ => FunctionAppRuntimeName.Custom + }; + + internal static string NormalizeRuntimeVersionForConfig(string runtime, string? runtimeVersion) + { + var version = string.IsNullOrWhiteSpace(runtimeVersion) ? GetDefaultRuntimeVersion(runtime) : runtimeVersion; + if (string.IsNullOrWhiteSpace(version)) + return string.Empty; + if (runtime == "java" && version.EndsWith(".0", StringComparison.Ordinal)) + version = version[..^2]; + return version; + } + + internal static AppServiceConfigurationDictionary BuildAppSettings(string runtime, string? runtimeVersion, bool requiresLinux, string storageConnectionString, bool includeWorkerRuntime = true) + { + var settings = new AppServiceConfigurationDictionary + { + Properties = { - return cachedResults; + ["AzureWebJobsStorage"] = storageConnectionString, + ["FUNCTIONS_EXTENSION_VERSION"] = "~4" } + }; + if (includeWorkerRuntime) + { + settings.Properties["FUNCTIONS_WORKER_RUNTIME"] = runtime; + } + var effectiveVersion = string.IsNullOrWhiteSpace(runtimeVersion) ? GetDefaultRuntimeVersion(runtime) : runtimeVersion; + if (!requiresLinux && runtime == "node" && !string.IsNullOrWhiteSpace(effectiveVersion)) + { + var major = ExtractMajorVersion(effectiveVersion!); + if (!string.IsNullOrEmpty(major)) + settings.Properties["WEBSITE_NODE_DEFAULT_VERSION"] = $"~{major}"; + } + return settings; + } + + internal static string? ExtractMajorVersion(string? version) => string.IsNullOrWhiteSpace(version) + ? null + : new string(version.Trim().TakeWhile(char.IsDigit).ToArray()) switch { "" => null, var v => v }; + + + internal static string InferTier(string skuName) + { + var upper = skuName.Trim().ToUpperInvariant(); + + if (upper.StartsWith("FC")) + return "FlexConsumption"; + if (upper.StartsWith("EP")) + return "ElasticPremium"; + if (upper.StartsWith("P") && (upper.Contains("V3") || upper.StartsWith("P1V3") || upper.StartsWith("P2V3") || upper.StartsWith("P3V3"))) + return "PremiumV3"; + if (upper.StartsWith("P")) + return "Premium"; + if (upper.StartsWith("B")) + return "Basic"; + if (upper.StartsWith("S")) + return "Standard"; + if (upper.StartsWith("Y")) + return "Dynamic"; + + return "Standard"; + } - try + internal static void SanitizeAppSettingsForFlexConsumption(AppServiceConfigurationDictionary settings) + { + if (settings?.Properties is null) + return; + settings.Properties.Remove("WEBSITE_NODE_DEFAULT_VERSION"); + settings.Properties.Remove("FUNCTIONS_WORKER_RUNTIME"); + } + + internal static async Task EnsureMinimalContainerApp(ResourceGroupResource rg, string name, string location, string runtime, string storageConnectionString, string? containerAppsEnvironmentName = null) + { + var envs = rg.GetContainerAppManagedEnvironments(); + var envName = containerAppsEnvironmentName ?? $"{name}-env"; + + ContainerAppManagedEnvironmentResource env; + if (await envs.ExistsAsync(envName)) + { + env = await envs.GetAsync(envName); + } + else + { + var envData = new ContainerAppManagedEnvironmentData(location); + env = (await envs.CreateOrUpdateAsync(WaitUntil.Completed, envName, envData)).Value; + } + + var apps = rg.GetContainerApps(); + var image = GetContainerImage(runtime); + var data = new ContainerAppData(location) + { + ManagedEnvironmentId = env.Id, + Configuration = new ContainerAppConfiguration() { - var resourceGroupResource = await subscriptionResource.GetResourceGroupAsync(resourceGroup); - if (!resourceGroupResource.HasValue) + Ingress = new ContainerAppIngressConfiguration() { - throw new Exception($"Resource group '{resourceGroup}' not found in subscription '{subscription}'"); + External = ContainerAppsDefaults.ExternalIngress, + TargetPort = ContainerAppsDefaults.IngressPort + } + }, + Template = new ContainerAppTemplate() + { + Containers = + { + new ContainerAppContainer() + { + Name = name, + Image = image, + Resources = new AppContainerResources() + { + Cpu = ContainerAppsDefaults.CpuCores, + Memory = ContainerAppsDefaults.Memory + }, + Env = + { + new ContainerAppEnvironmentVariable() + { + Name = "FUNCTIONS_WORKER_RUNTIME", + Value = runtime + }, + new ContainerAppEnvironmentVariable() + { + Name = "FUNCTIONS_EXTENSION_VERSION", + Value = "~4" + }, + new ContainerAppEnvironmentVariable() + { + Name = "AzureWebJobsStorage", + Value = storageConnectionString + } + } + } } - var site = await resourceGroupResource.Value.GetWebSites().GetAsync(functionAppName); - - TryAddFunctionApp(site.Value, functionApps); - await _cacheService.SetAsync(CacheGroup, cacheKey, functionApps, s_cacheDuration); } - catch (Exception ex) + }; + + var app = (await apps.CreateOrUpdateAsync(WaitUntil.Completed, name, data)).Value; + return app; + } + + internal static SiteConfigProperties? CreateWindowsPowerShellSiteConfig(string? runtimeVersion) + { + var version = string.IsNullOrWhiteSpace(runtimeVersion) ? GetDefaultRuntimeVersion("powershell") : runtimeVersion; + if (string.IsNullOrWhiteSpace(version)) + return null; + return new SiteConfigProperties { PowerShellVersion = version }; + } + + internal static FunctionAppStorage? BuildDeploymentStorage(string storageConnectionString) + { + if (string.IsNullOrWhiteSpace(storageConnectionString)) + return null; + var accountName = ExtractStorageAccountName(storageConnectionString); + if (string.IsNullOrWhiteSpace(accountName)) + return null; + return new FunctionAppStorage + { + StorageType = FunctionAppStorageType.BlobContainer, + Value = new Uri($"https://{accountName}.blob.core.windows.net/azure-webjobs-hosts"), + Authentication = new FunctionAppStorageAuthentication { - throw new Exception($"Error retrieving Function App '{functionAppName}' in resource group '{resourceGroup}': {ex.Message}", ex); + AuthenticationType = FunctionAppStorageAccountAuthenticationType.StorageAccountConnectionString, + StorageAccountConnectionStringName = "AzureWebJobsStorage" } + }; + } + + internal static string? ExtractStorageAccountName(string? connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + return null; + var parts = connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var p in parts) + { + if (p.StartsWith("AccountName=", StringComparison.OrdinalIgnoreCase)) + return p[12..]; } + return null; + } - return functionApps; + + internal static (bool RequiresLinux, string? NormalizedOs) ResolveOs(string runtime, HostingKind hostingKind, string? operatingSystem) + { + var forcedLinux = runtime == "python" || hostingKind == HostingKind.FlexConsumption || hostingKind == HostingKind.ContainerApp; + bool requiresLinux = forcedLinux; + + if (string.IsNullOrEmpty(operatingSystem)) + { + return (requiresLinux, null); + } + + if (forcedLinux && operatingSystem == "windows") + throw new InvalidOperationException("Selected runtime/plan requires Linux operating system."); + + if (!forcedLinux) + { + requiresLinux = operatingSystem == "linux"; + } + + return (requiresLinux, operatingSystem); } - private static async Task RetrieveAndAddFunctionApp(AsyncPageable sites, List functionApps) + internal static bool IsFlexConsumption(AppServicePlanData plan) { - await foreach (var site in sites) + return string.Equals(plan.Sku?.Tier, "FlexConsumption", StringComparison.OrdinalIgnoreCase); + } + + internal enum HostingKind + { + Consumption, + FlexConsumption, + Premium, + AppService, + ContainerApp + } + + internal readonly record struct CreateOptions( + string Runtime, + string? RuntimeVersion, + HostingKind HostingKind, + bool RequiresLinux, + string? ExplicitSku, + string? ExplicitOs); + + internal static class AppServiceStrategy + { + public static async Task CreateFunctionAppAsync( + SubscriptionResource subscription, + ResourceGroupResource rg, + string functionAppName, + string location, + string? planName, + CreateOptions options, + string? storageAccountName = null) { - TryAddFunctionApp(site, functionApps); + var plan = await EnsureAppServicePlan(rg, planName, functionAppName, location, options); + var storageConnection = await EnsureStorageForFunctionApp(subscription, rg, functionAppName, location, storageAccountName); + var site = await CreateAppServiceSiteAsync( + rg, + functionAppName, + location, + plan, + options, + storageConnection); + await ApplyAppSettings(site, options, storageConnection); + return ConvertToFunctionAppModel(site); } } - private static void TryAddFunctionApp(WebSiteResource site, List functionApps) + internal static class ContainerAppsStrategy { - if (site?.Data != null && site?.Data.Kind?.Contains("functionapp", StringComparison.OrdinalIgnoreCase) == true) + public static async Task CreateFunctionAppAsync( + SubscriptionResource subscription, + ResourceGroupResource rg, + string functionAppName, + string location, + CreateOptions options, + string? storageAccountName = null, + string? containerAppsEnvironmentName = null) { - var data = site.Data; - functionApps.Add(new(data.Name, data.Id.ResourceGroupName, data.Location.ToString(), data.AppServicePlanId.Name, - data.State, data.DefaultHostName, data.Tags)); + var storage = await EnsureStorageForFunctionApp(subscription, rg, functionAppName, location, storageAccountName); + var containerApp = await EnsureMinimalContainerApp(rg, functionAppName, location, options.Runtime, storage, containerAppsEnvironmentName); + var host = containerApp.Data.Configuration?.Ingress?.Fqdn ?? containerApp.Data.LatestRevisionName ?? containerApp.Data.Name; + return new FunctionAppInfo( + containerApp.Data.Name, + rg.Data.Name, + location, + "containerapp", + containerApp.Data.ProvisioningState.ToString(), + host, + "linux", + containerApp.Data.Tags?.ToDictionary(k => k.Key, v => v.Value)); } } } diff --git a/tools/Azure.Mcp.Tools.FunctionApp/src/Services/IFunctionAppService.cs b/tools/Azure.Mcp.Tools.FunctionApp/src/Services/IFunctionAppService.cs index baab3c518..cd36edb19 100644 --- a/tools/Azure.Mcp.Tools.FunctionApp/src/Services/IFunctionAppService.cs +++ b/tools/Azure.Mcp.Tools.FunctionApp/src/Services/IFunctionAppService.cs @@ -13,4 +13,20 @@ public interface IFunctionAppService string? resourceGroup, string? tenant = null, RetryPolicyOptions? retryPolicy = null); + + Task CreateFunctionApp( + string subscription, + string resourceGroup, + string functionAppName, + string location, + string? planName = null, + string? hostingKind = null, + string? sku = null, + string? runtime = null, + string? runtimeVersion = null, + string? os = null, + string? storageAccountName = null, + string? containerAppsEnvironmentName = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null); } diff --git a/tools/Azure.Mcp.Tools.FunctionApp/tests/Azure.Mcp.Tools.FunctionApp.LiveTests/FunctionAppCommandTests.cs b/tools/Azure.Mcp.Tools.FunctionApp/tests/Azure.Mcp.Tools.FunctionApp.LiveTests/FunctionAppCommandTests.cs index 7b3aac7c9..9e70cc1b4 100644 --- a/tools/Azure.Mcp.Tools.FunctionApp/tests/Azure.Mcp.Tools.FunctionApp.LiveTests/FunctionAppCommandTests.cs +++ b/tools/Azure.Mcp.Tools.FunctionApp/tests/Azure.Mcp.Tools.FunctionApp.LiveTests/FunctionAppCommandTests.cs @@ -174,4 +174,77 @@ public async Task Should_validate_required_parameters_for_get_command() }); Assert.False(missingSub.HasValue); } + + [Theory] + [InlineData("consumption", null, "python", null, "linux", null)] + [InlineData("flex", null, "dotnet-isolated", null, "linux", null)] + [InlineData("premium", null, "powershell", "windows", "windows", null)] + [InlineData("containerapp", null, "dotnet-isolated", null, "linux", null)] + [InlineData("appservice", "B2", "node", "windows", "windows", "22.0.0")] + [InlineData("appservice", "P0V3", "java", "linux", "linux", "21.0")] + public async Task Should_create_function_app( + string planType, + string? planSku, + string runtime, + string? operatingSystem, + string expectedOperatingSystem, + string? runtimeVersion) + { + var uniqueName = $"mcp-test-{planType}-{DateTime.UtcNow:MMddHHmmss}"; + + var result = await CallToolAsync( + "azmcp_functionapp_create", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", uniqueName }, + { "function-app", uniqueName }, + { "location", "westus" }, + { "plan-type", planType }, + { "plan-sku", planSku }, + { "runtime", runtime }, + { "os", operatingSystem }, + { "runtime-version", runtimeVersion } + }); + + Assert.NotNull(result); + var functionAppWrapper = result!.Value; + Assert.True(functionAppWrapper.TryGetProperty("functionApp", out var functionApp)); + Assert.True(functionApp.TryGetProperty("name", out var nameProp)); + Assert.Equal(uniqueName, nameProp.GetString()); + Assert.True(functionApp.TryGetProperty("resourceGroupName", out var rgProp)); + Assert.Equal(uniqueName, rgProp.GetString()); + Assert.True(functionApp.TryGetProperty("appServicePlanName", out var planProp)); + Assert.False(string.IsNullOrWhiteSpace(planProp.GetString())); + Assert.True(functionApp.TryGetProperty("operatingSystem", out var osProp)); + Assert.Equal(expectedOperatingSystem, osProp.GetString()); + } + + [Theory] + [InlineData("python", "windows")] + [InlineData("dotnet", "invalid-os")] + public async Task Should_fail_when_invalid_or_conflicting_os(string runtime, string os) + { + var uniqueFunctionAppName = $"test-functionapp-invalid-{runtime}-{DateTime.UtcNow:MMddHHmmss}"; + var result = await CallToolAsync( + "azmcp_functionapp_create", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName }, + { "function-app", uniqueFunctionAppName }, + { "location", "westus" }, + { "runtime", runtime }, + { "os", os } + }); + + if (result.HasValue) + { + Assert.True(result.Value.TryGetProperty("message", out _)); + } + else + { + Assert.Null(result); + } + } } diff --git a/tools/Azure.Mcp.Tools.FunctionApp/tests/Azure.Mcp.Tools.FunctionApp.UnitTests/FunctionApp/FunctionAppCreateCommandTests.cs b/tools/Azure.Mcp.Tools.FunctionApp/tests/Azure.Mcp.Tools.FunctionApp.UnitTests/FunctionApp/FunctionAppCreateCommandTests.cs new file mode 100644 index 000000000..07c7fad7a --- /dev/null +++ b/tools/Azure.Mcp.Tools.FunctionApp/tests/Azure.Mcp.Tools.FunctionApp.UnitTests/FunctionApp/FunctionAppCreateCommandTests.cs @@ -0,0 +1,898 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.FunctionApp.Commands; +using Azure.Mcp.Tools.FunctionApp.Commands.FunctionApp; +using Azure.Mcp.Tools.FunctionApp.Models; +using Azure.Mcp.Tools.FunctionApp.Services; +using Azure.ResourceManager.AppService; +using Azure.ResourceManager.AppService.Models; +using Azure.ResourceManager.Storage.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace AzureMcp.FunctionApp.UnitTests.FunctionApp; + +public sealed class FunctionAppCreateCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IFunctionAppService _service; + private readonly ILogger _logger; + private readonly FunctionAppCreateCommand _command; + + public FunctionAppCreateCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection(); + collection.AddSingleton(_service); + _serviceProvider = collection.BuildServiceProvider(); + + _command = new(_logger); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = _command.GetCommand(); + Assert.Equal("create", command.Name); + Assert.NotNull(command.Description); + Assert.NotEmpty(command.Description); + } + + [Theory] + [InlineData("--subscription sub --resource-group rg --function-app myapp --location eastus", true)] + [InlineData("--subscription sub --resource-group rg --function-app myapp", false)] + [InlineData("--subscription sub --location eastus --function-app myapp", false)] + [InlineData("--resource-group rg --location eastus --function-app myapp", false)] + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + if (shouldSucceed) + { + _service.CreateFunctionApp( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new FunctionAppInfo("myapp", "rg", "eastus", "plan", "Running", "myapp.azurewebsites.net", null, null)); + } + + var context = new CommandContext(_serviceProvider); + var parseResult = _command.GetCommand().Parse(args); + + var response = await _command.ExecuteAsync(context, parseResult); + + Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); + } + + [Fact] + public async Task ExecuteAsync_ReturnsCreatedFunctionApp() + { + var expected = new FunctionAppInfo("myapp", "rg", "eastus", "plan", "Running", "myapp.azurewebsites.net", null, null); + _service.CreateFunctionApp( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expected); + + var context = new CommandContext(_serviceProvider); + var parseResult = _command.GetCommand().Parse("--subscription sub --resource-group rg --function-app myapp --location eastus"); + + var response = await _command.ExecuteAsync(context, parseResult); + + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json, FunctionAppJsonContext.Default.FunctionAppCreateCommandResult); + Assert.NotNull(result); + Assert.Equal("myapp", result.FunctionApp.Name); + } + + [Fact] + public async Task ExecuteAsync_HandlesServiceErrors() + { + _service.CreateFunctionApp( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(new Exception("Create error"))); + + var context = new CommandContext(_serviceProvider); + var parseResult = _command.GetCommand().Parse("--subscription sub --resource-group rg --function-app myapp --location eastus"); + + var response = await _command.ExecuteAsync(context, parseResult); + + Assert.Equal(HttpStatusCode.InternalServerError, response.Status); + Assert.Contains("Create error", response.Message); + } + + [Fact] + public async Task ExecuteAsync_PassesPlanTypeAndRuntimeVersionToService() + { + var expected = new FunctionAppInfo("myapp", "rg", "eastus", "plan", "Running", "myapp.azurewebsites.net", null, null); + _service.CreateFunctionApp( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expected); + + var context = new CommandContext(_serviceProvider); + var args = "--subscription sub --resource-group rg --function-app myapp --location eastus --plan-type flex --runtime node --runtime-version 22"; + var parseResult = _command.GetCommand().Parse(args); + + var response = await _command.ExecuteAsync(context, parseResult); + + Assert.Equal(HttpStatusCode.OK, response.Status); + } + + [Fact] + public async Task ExecuteAsync_DefaultsRuntimeToDotnet_WhenNotProvided() + { + var expected = new FunctionAppInfo("myapp", "rg", "eastus", "plan", "Running", "myapp.azurewebsites.net", null, null); + _service.CreateFunctionApp( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expected); + + var context = new CommandContext(_serviceProvider); + var parseResult = _command.GetCommand().Parse("--subscription sub --resource-group rg --function-app myapp --location eastus"); + + var response = await _command.ExecuteAsync(context, parseResult); + + Assert.Equal(HttpStatusCode.OK, response.Status); + } + + [Theory] + [InlineData("", null, null)] + [InlineData("--plan-type consumption", "consumption", null)] + [InlineData("--plan-type flex", "flex", null)] + [InlineData("--plan-type premium", "premium", null)] + [InlineData("--plan-type appservice", "appservice", null)] + [InlineData("--plan-sku S1", null, "S1")] + [InlineData("--plan-type premium --plan-sku EP2", "premium", "EP2")] + [InlineData("--plan-type containerapp", "containerapp", null)] + public async Task ExecuteAsync_PlanSelection_Matrix(string argsSuffix, string? expectedPlanType, string? expectedPlanSku) + { + var expected = new FunctionAppInfo("myapp", "rg", "eastus", expectedPlanType ?? "plan", "Running", "myapp.azurewebsites.net", null, null); + _service.CreateFunctionApp( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expected); + + var baseArgs = "--subscription sub --resource-group rg --function-app myapp --location eastus"; + var fullArgs = string.IsNullOrWhiteSpace(argsSuffix) ? baseArgs : $"{baseArgs} {argsSuffix}"; + var context = new CommandContext(_serviceProvider); + var parseResult = _command.GetCommand().Parse(fullArgs); + var response = await _command.ExecuteAsync(context, parseResult); + + Assert.Equal(HttpStatusCode.OK, response.Status); + + await _service.Received(1).CreateFunctionApp( + "sub", + "rg", + "myapp", + "eastus", + Arg.Any(), + Arg.Is(pt => pt == expectedPlanType), + Arg.Is(ps => ps == expectedPlanSku), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + + [Theory] + [InlineData("python", null, "Python|3.12")] + [InlineData("python", "3.11", "Python|3.11")] + [InlineData("node", null, "Node|22")] + [InlineData("node", "20", "Node|20")] + [InlineData("dotnet", null, "DOTNET|8.0")] + [InlineData("dotnet", "7.0", "DOTNET|7.0")] + [InlineData("java", "21.0", "Java|21")] + [InlineData("java", null, "Java|17")] + [InlineData("powershell", null, "PowerShell|7.4")] + [InlineData("powershell", "7.3", "PowerShell|7.3")] + public void CreateLinuxSiteConfig_ComposesLinuxFxVersion(string runtime, string? version, string expected) + { + SiteConfigProperties? cfg = FunctionAppService.CreateLinuxSiteConfig(runtime, version); + Assert.NotNull(cfg); + Assert.Equal(expected, cfg!.LinuxFxVersion); + } + + [Theory] + [InlineData("node", "22", false, "~22")] + [InlineData("node", "22.3.1", false, "~22")] + [InlineData("node", "22 LTS", false, "~22")] + [InlineData("node", null, false, "~22")] + [InlineData("node", "20", true, null)] + [InlineData("python", "3.12", false, null)] + public void BuildAppSettings_ComposesWebsiteNodeDefaultVersion_WhenApplicable(string runtime, string? runtimeVersion, bool requiresLinux, string? expected) + { + var dict = FunctionAppService.BuildAppSettings(runtime, runtimeVersion, requiresLinux, "UseDevelopmentStorage=true"); + + dict.Properties.TryGetValue("WEBSITE_NODE_DEFAULT_VERSION", out var actualObj); + var actual = actualObj as string; + + Assert.Equal(expected, actual); + Assert.Equal(runtime, dict.Properties["FUNCTIONS_WORKER_RUNTIME"]); + Assert.Equal("~4", dict.Properties["FUNCTIONS_EXTENSION_VERSION"]); + Assert.Equal("UseDevelopmentStorage=true", dict.Properties["AzureWebJobsStorage"]); + } + + [Fact] + public async Task ExecuteAsync_ExistingPlan_NoPlanTypeOrSku() + { + var expected = new FunctionAppInfo("myapp", "rg", "eastus", "existingPlan", "Running", "myapp.azurewebsites.net", null, null); + _service.CreateFunctionApp( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expected); + var context = new CommandContext(_serviceProvider); + var parseResult = _command.GetCommand().Parse("--subscription sub --resource-group rg --function-app myapp --location eastus --app-service-plan existingPlan"); + var response = await _command.ExecuteAsync(context, parseResult); + Assert.Equal(HttpStatusCode.OK, response.Status); + await _service.Received(1).CreateFunctionApp( + "sub", "rg", "myapp", "eastus", + Arg.Is(p => p == "existingPlan"), + Arg.Is(pt => pt == null), + Arg.Is(ps => ps == null), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_SkuPrecedence_OverridesPlanType() + { + var expected = new FunctionAppInfo("myapp", "rg", "eastus", "plan", "Running", "myapp.azurewebsites.net", null, null); + _service.CreateFunctionApp( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expected); + var context = new CommandContext(_serviceProvider); + var parseResult = _command.GetCommand().Parse("--subscription sub --resource-group rg --function-app myapp --location eastus --plan-type flex --plan-sku B1"); + var response = await _command.ExecuteAsync(context, parseResult); + Assert.Equal(HttpStatusCode.OK, response.Status); + await _service.Received(1).CreateFunctionApp( + "sub", "rg", "myapp", "eastus", + Arg.Any(), + Arg.Is(pt => pt == "flex"), + Arg.Is(ps => ps == "B1"), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_DotnetIsolated_RuntimeAccepted() + { + var expected = new FunctionAppInfo("myapp", "rg", "eastus", "plan", "Running", "myapp.azurewebsites.net", null, null); + _service.CreateFunctionApp( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expected); + var context = new CommandContext(_serviceProvider); + var parseResult = _command.GetCommand().Parse("--subscription sub --resource-group rg --function-app myapp --location eastus --runtime dotnet-isolated"); + var response = await _command.ExecuteAsync(context, parseResult); + Assert.Equal(HttpStatusCode.OK, response.Status); + await _service.Received(1).CreateFunctionApp( + "sub", "rg", "myapp", "eastus", + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Is(r => r == "dotnet-isolated"), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_PassesOperatingSystemOption() + { + var expected = new FunctionAppInfo("myapp", "rg", "eastus", "plan", "Running", "myapp.azurewebsites.net", null, null); + _service.CreateFunctionApp( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Is(os => os == "linux"), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expected); + var context = new CommandContext(_serviceProvider); + var parseResult = _command.GetCommand().Parse("--subscription sub --resource-group rg --function-app myapp --location eastus --os linux"); + var response = await _command.ExecuteAsync(context, parseResult); + Assert.Equal(HttpStatusCode.OK, response.Status); + await _service.Received(1).CreateFunctionApp( + "sub", "rg", "myapp", "eastus", + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Is(os => os == "linux"), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public void BuildAppSettings_NodeOnLinux_DoesNotSetWebsiteNodeDefaultVersion() + { + var dict = FunctionAppService.BuildAppSettings("node", "22", true, "UseDevelopmentStorage=true"); + Assert.False(dict.Properties.ContainsKey("WEBSITE_NODE_DEFAULT_VERSION")); + } + + [Fact] + public void BuildAppSettings_FlexConsumption_OmitsFunctionsWorkerRuntime() + { + var dict = FunctionAppService.BuildAppSettings("dotnet", null, false, "UseDevelopmentStorage=true", includeWorkerRuntime: false); + Assert.False(dict.Properties.ContainsKey("FUNCTIONS_WORKER_RUNTIME")); + Assert.Equal("~4", dict.Properties["FUNCTIONS_EXTENSION_VERSION"]); + Assert.Equal("UseDevelopmentStorage=true", dict.Properties["AzureWebJobsStorage"]); + } + + [Fact] + public void CreateStorageAccountOptions_Defaults() + { + var opts = FunctionAppService.CreateStorageAccountOptions("eastus"); + Assert.Equal(StorageSkuName.StandardLrs, opts.Sku.Name); + Assert.Equal(StorageKind.StorageV2, opts.Kind); + Assert.Equal(StorageAccountAccessTier.Hot, opts.AccessTier); + Assert.True(opts.EnableHttpsTrafficOnly); + Assert.False(opts.AllowBlobPublicAccess); + Assert.False(opts.IsHnsEnabled); + } + + [Theory] + [InlineData("dotnet", "mcr.microsoft.com/azure-functions/dotnet:4")] + [InlineData("dotnet-isolated", "mcr.microsoft.com/azure-functions/dotnet-isolated:4")] + [InlineData("node", "mcr.microsoft.com/azure-functions/node:4")] + [InlineData("python", "mcr.microsoft.com/azure-functions/python:4")] + [InlineData("java", "mcr.microsoft.com/azure-functions/java:4")] + [InlineData("powershell", "mcr.microsoft.com/azure-functions/powershell:4")] + [InlineData("unknownRuntime", "mcr.microsoft.com/azure-functions/dotnet-isolated:4")] + public void GetContainerImage_MapsRuntimes(string runtime, string expectedImage) + { + var image = FunctionAppService.GetContainerImage(runtime); + Assert.Equal(expectedImage, image); + } + + [Theory] + [InlineData("node", "flex", null, true, null)] + [InlineData("dotnet", "flex", null, true, null)] + [InlineData("dotnet", null, null, false, null)] + [InlineData("java", null, null, false, null)] + [InlineData("python", "appservice", null, true, null)] + [InlineData("node", null, "linux", true, "linux")] + [InlineData("node", "containerapp", null, true, null)] + [InlineData("dotnet", null, "windows", false, "windows")] + public void ResolveOs_CorrectlyEvaluates(string runtime, string? planType, string? operatingSystem, bool expectedRequiresLinux, string? expectedNormalizedOs) + { + var hostingKind = FunctionAppService.ParseHostingKind(planType); + var (actualRequiresLinux, actualNormalizedOs) = FunctionAppService.ResolveOs(runtime, hostingKind, operatingSystem); + Assert.Equal(expectedRequiresLinux, actualRequiresLinux); + Assert.Equal(expectedNormalizedOs, actualNormalizedOs); + } + + [Fact] + public void ResolveOs_ThrowsWhenPythonWithWindows() + { + var hostingKind = FunctionAppService.ParseHostingKind(null); + Assert.Throws(() => + FunctionAppService.ResolveOs("python", hostingKind, "windows")); + } + + [Theory] + [InlineData(null, "7.4")] + [InlineData("7.3", "7.3")] + public void CreateWindowsPowerShellSiteConfig_SetsVersion(string? input, string expected) + { + var cfg = FunctionAppService.CreateWindowsPowerShellSiteConfig(input); + Assert.NotNull(cfg); + Assert.Equal(expected, cfg!.PowerShellVersion); + } + + [Theory] + [InlineData("DefaultEndpointsProtocol=https;AccountName=account1name123457891011;AccountKey=key;EndpointSuffix=core.windows.net", "account1name123457891011")] + [InlineData("AccountKey=key;EndpointSuffix=core.windows.net;AccountName=abc123", "abc123")] + [InlineData("AccountKey=key;EndpointSuffix=core.windows.net", null)] + [InlineData(null, null)] + public void ExtractStorageAccountName_ParsesName(string? connection, string? expected) + { + var actual = FunctionAppService.ExtractStorageAccountName(connection); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("DefaultEndpointsProtocol=https;AccountName=account-name1;AccountKey=key;EndpointSuffix=core.windows.net", true)] + [InlineData("AccountKey=key;EndpointSuffix=core.windows.net", false)] + [InlineData(null, false)] + public void BuildDeploymentStorage_CreatesStorageObjectWhenAccountPresent(string? connection, bool expectCreated) + { + var storage = FunctionAppService.BuildDeploymentStorage(connection ?? string.Empty); + if (expectCreated) + { + Assert.NotNull(storage); + Assert.Equal(FunctionAppStorageType.BlobContainer, storage!.StorageType); + Assert.NotNull(storage.Authentication); + Assert.EndsWith(".blob.core.windows.net/azure-webjobs-hosts", storage.Value.AbsoluteUri); + } + else + { + Assert.Null(storage); + } + } + + [Theory] + [InlineData(null, "consumption")] + [InlineData("", "consumption")] + [InlineData("flex", "flexconsumption")] + [InlineData("flexconsumption", "flexconsumption")] + [InlineData("premium", "premium")] + [InlineData("functionspremium", "premium")] + [InlineData("appservice", "appservice")] + [InlineData("containerapp", "containerapp")] + [InlineData("containerapps", "containerapp")] + [InlineData("unknown", "consumption")] + public void ParseHostingKind_MapsCorrectly(string? planType, string expected) + { + var result = FunctionAppService.ParseHostingKind(planType); + var expectedEnum = expected switch + { + "flexconsumption" => FunctionAppService.HostingKind.FlexConsumption, + "premium" => FunctionAppService.HostingKind.Premium, + "appservice" => FunctionAppService.HostingKind.AppService, + "containerapp" => FunctionAppService.HostingKind.ContainerApp, + _ => FunctionAppService.HostingKind.Consumption + }; + Assert.Equal(expectedEnum, result); + } + + [Theory] + [InlineData("FC1", "FlexConsumption")] + [InlineData("fc2", "FlexConsumption")] + [InlineData("EP1", "ElasticPremium")] + [InlineData("ep2", "ElasticPremium")] + [InlineData("P1V3", "PremiumV3")] + [InlineData("P2V3", "PremiumV3")] + [InlineData("P3V3", "PremiumV3")] + [InlineData("P1", "Premium")] + [InlineData("P2", "Premium")] + [InlineData("B1", "Basic")] + [InlineData("B2", "Basic")] + [InlineData("S1", "Standard")] + [InlineData("S2", "Standard")] + [InlineData("Y1", "Dynamic")] + [InlineData("unknown", "Standard")] + public void InferTier_CorrectlyInfersTierFromSku(string sku, string expectedTier) + { + var result = FunctionAppService.InferTier(sku); + Assert.Equal(expectedTier, result); + } + + + [Theory] + [InlineData("", "", "", "")] + [InlineData("sub", "", "", "")] + [InlineData("sub", "rg", "", "")] + [InlineData("sub", "rg", "app", "")] + public void ValidateAndNormalizeInputs_ThrowsForMissingRequiredParameters( + string subscription, string resourceGroup, string functionAppName, string location) + { + Assert.Throws(() => + FunctionAppService.ValidateAndNormalizeInputs( + subscription, resourceGroup, functionAppName, location, + null, null, null, null, null, null, null)); + } + + [Fact] + public void ValidateAndNormalizeInputs_NormalizesInputsCorrectly() + { + var result = FunctionAppService.ValidateAndNormalizeInputs( + "sub", + "rg", + "app", + "eastus", + " NODE ", + " 20 ", + " flex ", + " EP1 ", + " LINUX ", + " storage123abc ", + null); + + Assert.Equal("node", result.Runtime); + Assert.Equal("20", result.RuntimeVersion); + Assert.Equal("flex", result.PlanType); + Assert.Equal("EP1", result.PlanSku); + Assert.Equal("linux", result.OperatingSystem); + Assert.Equal("storage123abc", result.StorageAccountName); + Assert.Null(result.ContainerAppsEnvironmentName); + } + + [Fact] + public void ValidateAndNormalizeInputs_DefaultsRuntimeToDotnet() + { + var result = FunctionAppService.ValidateAndNormalizeInputs( + "sub", "rg", "app", "eastus", null, null, null, null, null, null, null); + + Assert.Equal("dotnet", result.Runtime); + } + + [Theory] + [InlineData("invalidRuntime")] + [InlineData("javascript")] + [InlineData("csharp")] + public void ValidateParameterCombinations_ThrowsForUnsupportedRuntime(string runtime) + { + var inputs = new FunctionAppService.NormalizedInputs(runtime, null, null, null, null, null, null); + Assert.Throws(() => + FunctionAppService.ValidateParameterCombinations(inputs)); + } + + [Fact] + public void ValidateParameterCombinations_ThrowsForPythonWithWindows() + { + var inputs = new FunctionAppService.NormalizedInputs("python", null, null, null, "windows", null, null); + Assert.Throws(() => + FunctionAppService.ValidateParameterCombinations(inputs)); + } + + [Theory] + [InlineData("ab")] + [InlineData("tooLongStorageAccountName123")] + [InlineData("Storage123")] + [InlineData("storage-123")] + public void ValidateParameterCombinations_ThrowsForInvalidStorageAccountName(string storageAccountName) + { + var inputs = new FunctionAppService.NormalizedInputs("dotnet", null, null, null, null, storageAccountName, null); + Assert.Throws(() => + FunctionAppService.ValidateParameterCombinations(inputs)); + } + + [Fact] + public void ValidateParameterCombinations_ThrowsForContainerAppsEnvironmentWithoutContainerApps() + { + var inputs = new FunctionAppService.NormalizedInputs("dotnet", null, "consumption", null, null, null, "env123"); + Assert.Throws(() => + FunctionAppService.ValidateParameterCombinations(inputs)); + } + + [Fact] + public void ValidateParameterCombinations_ThrowsForContainerAppsWithSku() + { + var inputs = new FunctionAppService.NormalizedInputs("dotnet", null, "containerapp", "B1", null, null, null); + Assert.Throws(() => + FunctionAppService.ValidateParameterCombinations(inputs)); + } + + [Fact] + public void ValidateParameterCombinations_ThrowsForFlexConsumptionDotnetWithVersion() + { + var inputs = new FunctionAppService.NormalizedInputs("dotnet", "8.0", "flex", null, null, null, null); + Assert.Throws(() => + FunctionAppService.ValidateParameterCombinations(inputs)); + } + + [Theory] + [InlineData("invalid")] + [InlineData("mac")] + [InlineData("WINDOWS")] + public void ValidateParameterCombinations_ThrowsForInvalidOperatingSystem(string os) + { + var inputs = new FunctionAppService.NormalizedInputs("dotnet", null, null, null, os, null, null); + Assert.Throws(() => + FunctionAppService.ValidateParameterCombinations(inputs)); + } + + [Theory] + [InlineData("dotnet", null, "flex", null, "dotnet-isolated", "8.0")] + [InlineData("dotnet", "8.0", "consumption", null, "dotnet", "8.0")] + [InlineData("node", "20", "premium", null, "node", "20")] + [InlineData("python", null, "appservice", null, "python", "3.12")] + public void BuildCreateOptions_ConfiguresCorrectly(string runtime, string? runtimeVersion, string? planType, string? os, string expectedRuntime, string expectedRuntimeVersion) + { + var inputs = new FunctionAppService.NormalizedInputs(runtime, runtimeVersion, planType, null, os, null, null); + var result = FunctionAppService.BuildCreateOptions(inputs); + + Assert.Equal(expectedRuntime, result.Runtime); + Assert.Equal(expectedRuntimeVersion, result.RuntimeVersion); + } + + [Theory] + [InlineData(true, "dotnet", "8.0", "DOTNET|8.0")] + [InlineData(true, "node", "20", "Node|20")] + [InlineData(true, "python", null, "Python|3.12")] + [InlineData(false, "dotnet", "8.0", null)] + [InlineData(false, "powershell", "7.4", "7.4")] + [InlineData(false, "node", "20", null)] + public void BuildSiteConfig_ConfiguresCorrectly(bool isLinux, string runtime, string? runtimeVersion, string? expectedValue) + { + var options = new FunctionAppService.CreateOptions(runtime, runtimeVersion, FunctionAppService.HostingKind.Consumption, isLinux, null, null); + var result = FunctionAppService.BuildSiteConfig(isLinux, options); + + if (expectedValue == null) + { + if (isLinux || runtime != "powershell") + Assert.Null(result); + } + else if (isLinux) + { + Assert.NotNull(result); + Assert.Equal(expectedValue, result!.LinuxFxVersion); + } + else + { + Assert.NotNull(result); + Assert.Equal(expectedValue, result!.PowerShellVersion); + } + } + + [Theory] + [InlineData(true, "functionapp,linux")] + [InlineData(false, "functionapp")] + public void BuildKind_ConfiguresCorrectly(bool isLinux, string expected) + { + var result = FunctionAppService.BuildKind(isLinux); + Assert.Equal(expected, result); + } + + [Fact] + public void ValidateExistingPlan_ThrowsForWindowsPlanWithLinuxRuntime() + { + var planData = new AppServicePlanData("eastus") { IsReserved = false }; + var plan = Substitute.For(); + plan.Data.Returns(planData); + var options = new FunctionAppService.CreateOptions("python", "3.12", FunctionAppService.HostingKind.Consumption, true, null, null); + + Assert.Throws(() => + FunctionAppService.ValidateExistingPlan(plan, "test-plan", options)); + } + + [Fact] + public void ValidateExistingPlan_ThrowsForNonFlexPlanWithFlexHosting() + { + var sku = new AppServiceSkuDescription { Tier = "Dynamic" }; + var planData = new AppServicePlanData("eastus") { Sku = sku, IsReserved = true }; + var plan = Substitute.For(); + plan.Data.Returns(planData); + var options = new FunctionAppService.CreateOptions("dotnet", "8.0", FunctionAppService.HostingKind.FlexConsumption, true, null, null); + + Assert.Throws(() => + FunctionAppService.ValidateExistingPlan(plan, "test-plan", options)); + } + + [Fact] + public void ValidateExistingPlan_ThrowsForNonPremiumPlanWithPremiumHosting() + { + var sku = new AppServiceSkuDescription { Tier = "Dynamic" }; + var planData = new AppServicePlanData("eastus") { Sku = sku, IsReserved = true }; + var plan = Substitute.For(); + plan.Data.Returns(planData); + var options = new FunctionAppService.CreateOptions("dotnet", "8.0", FunctionAppService.HostingKind.Premium, true, null, null); + + Assert.Throws(() => + FunctionAppService.ValidateExistingPlan(plan, "test-plan", options)); + } + + [Theory] + [InlineData("functionapp", true)] + [InlineData("functionapp,linux", true)] + [InlineData("app", false)] + [InlineData("web", false)] + [InlineData(null, false)] + public void IsFunctionApp_DetectsCorrectly(string? kind, bool expected) + { + var siteData = new WebSiteData("eastus") { Kind = kind }; + var result = FunctionAppService.IsFunctionApp(siteData); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("my-app", "myapp")] + [InlineData("My-App-123", "myapp123")] + [InlineData("@#$%", "func")] + [InlineData("very-long-function-app-name", "verylongfunctionap")] + public void CreateStorageAccountName_GeneratesValidName(string functionAppName, string expectedPrefix) + { + var result = FunctionAppService.CreateStorageAccountName(functionAppName); + + Assert.True(result.Length >= 9 && result.Length <= 24); + Assert.StartsWith(expectedPrefix, result); + Assert.True(result.All(c => char.IsLetterOrDigit(c) && (char.IsDigit(c) || char.IsLower(c)))); + } + + [Fact] + public void BuildConnectionString_FormatsCorrectly() + { + var result = FunctionAppService.BuildConnectionString("storageaccount", "key123"); + var expected = "DefaultEndpointsProtocol=https;AccountName=storageaccount;AccountKey=key123;EndpointSuffix=core.windows.net"; + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("FlexConsumption", true)] + [InlineData("flexconsumption", true)] + [InlineData("Dynamic", false)] + [InlineData("ElasticPremium", false)] + [InlineData(null, false)] + public void IsFlexConsumption_DetectsCorrectly(string? tier, bool expected) + { + var sku = tier != null ? new AppServiceSkuDescription { Tier = tier } : null; + var planData = new AppServicePlanData("eastus") { Sku = sku }; + var result = FunctionAppService.IsFlexConsumption(planData); + Assert.Equal(expected, result); + } + + [Fact] + public void MapToFunctionAppRuntimeName_MapsCorrectly() + { + Assert.Equal(FunctionAppRuntimeName.DotnetIsolated, FunctionAppService.MapToFunctionAppRuntimeName("dotnet")); + Assert.Equal(FunctionAppRuntimeName.DotnetIsolated, FunctionAppService.MapToFunctionAppRuntimeName("dotnet-isolated")); + Assert.Equal(FunctionAppRuntimeName.Node, FunctionAppService.MapToFunctionAppRuntimeName("node")); + Assert.Equal(FunctionAppRuntimeName.Java, FunctionAppService.MapToFunctionAppRuntimeName("java")); + Assert.Equal(FunctionAppRuntimeName.Python, FunctionAppService.MapToFunctionAppRuntimeName("python")); + Assert.Equal(FunctionAppRuntimeName.Powershell, FunctionAppService.MapToFunctionAppRuntimeName("powershell")); + Assert.Equal(FunctionAppRuntimeName.Custom, FunctionAppService.MapToFunctionAppRuntimeName("custom")); + Assert.Equal(FunctionAppRuntimeName.Custom, FunctionAppService.MapToFunctionAppRuntimeName("unknown")); + } + + [Theory] + [InlineData("dotnet", "8.0", "8.0")] + [InlineData("java", "21.0", "21")] + [InlineData("java", "17", "17")] + [InlineData("node", null, "22")] + [InlineData("python", "", "3.12")] + public void NormalizeRuntimeVersionForConfig_NormalizesCorrectly(string runtime, string? runtimeVersion, string expected) + { + var result = FunctionAppService.NormalizeRuntimeVersionForConfig(runtime, runtimeVersion); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("22", "22")] + [InlineData("22.3.1", "22")] + [InlineData("22 LTS", "22")] + [InlineData("abc", null)] + [InlineData(null, null)] + [InlineData("", null)] + public void ExtractMajorVersion_ExtractsCorrectly(string? version, string? expected) + { + var result = FunctionAppService.ExtractMajorVersion(version); + Assert.Equal(expected, result); + } +} diff --git a/tools/Azure.Mcp.Tools.FunctionApp/tests/Azure.Mcp.Tools.FunctionApp.UnitTests/FunctionApp/FunctionAppGetCommandTests.cs b/tools/Azure.Mcp.Tools.FunctionApp/tests/Azure.Mcp.Tools.FunctionApp.UnitTests/FunctionApp/FunctionAppGetCommandTests.cs index 738cdfa53..a99591b99 100644 --- a/tools/Azure.Mcp.Tools.FunctionApp/tests/Azure.Mcp.Tools.FunctionApp.UnitTests/FunctionApp/FunctionAppGetCommandTests.cs +++ b/tools/Azure.Mcp.Tools.FunctionApp/tests/Azure.Mcp.Tools.FunctionApp.UnitTests/FunctionApp/FunctionAppGetCommandTests.cs @@ -46,8 +46,8 @@ public async Task ExecuteAsync_Listing_ValidatesInputCorrectly(string args, bool { var testFunctionApps = new List { - new("functionApp1", null, "eastus", "plan1", "Running", "functionapp1.azurewebsites.net", null), - new("functionApp2", null, "westus", "plan2", "Stopped", "functionapp2.azurewebsites.net", null) + new("functionApp1", null, "eastus", "plan1", "Running", "functionapp1.azurewebsites.net", "windows", null), + new("functionApp2", null, "westus", "plan2", "Stopped", "functionapp2.azurewebsites.net", "linux", null) }; _service.GetFunctionApp( Arg.Any(), @@ -83,8 +83,8 @@ public async Task ExecuteAsync_ReturnsFunctionAppList() // Arrange var expectedFunctionApps = new List { - new("functionApp1", "rg1", "eastus", "plan1", "Running", "functionapp1.azurewebsites.net", null), - new("functionApp2", "rg2", "westus", "plan2", "Stopped", "functionapp2.azurewebsites.net", null) + new("functionApp1", "rg1", "eastus", "plan1", "Running", "functionapp1.azurewebsites.net", "windows", null), + new("functionApp2", "rg2", "westus", "plan2", "Stopped", "functionapp2.azurewebsites.net", "linux", null) }; _service.GetFunctionApp( Arg.Any(), @@ -191,8 +191,8 @@ public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldS { if (shouldSucceed) { - _service.GetFunctionApp(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns([new FunctionAppInfo("app1", "rg1", "eastus", "plan1", "Running", "app1.azurewebsites.net", null)]); + _service.GetFunctionApp(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([new FunctionAppInfo("app1", "rg1", "eastus", "plan1", "Running", "app1.azurewebsites.net", "windows", null)]); } var context = new CommandContext(_serviceProvider); @@ -206,8 +206,8 @@ public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldS [Fact] public async Task ExecuteAsync_ReturnsFunctionApp() { - var expected = new FunctionAppInfo("app1", "rg1", "eastus", "plan1", "Running", "app1.azurewebsites.net", null); - _service.GetFunctionApp(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + var expected = new FunctionAppInfo("app1", "rg1", "eastus", "plan1", "Running", "app1.azurewebsites.net", "windows", null); + _service.GetFunctionApp(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns([expected]); var context = new CommandContext(_serviceProvider);