Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@
- <entry>
- Add Dockerfile for python 3.13 local build environment (#4611)
- Add support for Python 3.14 (#4668)
- Implement preview feature to apply host configuration profiles on 'func init' command (#4675)
- Implement preview feature to apply host configuration profiles via 'func init' command (#4675)
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using Azure.Functions.Cli.Actions.AzureActions;
using Azure.Functions.Cli.Actions.LocalActions;
using Azure.Functions.Cli.Arm;
using Azure.Functions.Cli.Common;
using Azure.Functions.Cli.ConfigurationProfiles;
using Azure.Functions.Cli.ContainerApps.Models;
using Azure.Functions.Cli.Helpers;
using Azure.Functions.Cli.Interfaces;
Expand All @@ -19,9 +20,9 @@ internal class AzureContainerAppsDeployAction : BaseAzureAction
{
private readonly CreateFunctionAction _createFunctionAction;

public AzureContainerAppsDeployAction(ITemplatesManager templatesManager, ISecretsManager secretsManager, IContextHelpManager contextHelpManager)
public AzureContainerAppsDeployAction(ITemplatesManager templatesManager, ISecretsManager secretsManager, IContextHelpManager contextHelpManager, IEnumerable<IConfigurationProfile> configurationProfiles)
{
_createFunctionAction = new CreateFunctionAction(templatesManager, secretsManager, contextHelpManager);
_createFunctionAction = new CreateFunctionAction(templatesManager, secretsManager, contextHelpManager, configurationProfiles);
}

public string ImageName { get; private set; }
Expand Down
9 changes: 6 additions & 3 deletions src/Cli/func/Actions/LocalActions/CreateFunctionAction.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using System.Text.RegularExpressions;
using Azure.Functions.Cli.Common;
using Azure.Functions.Cli.ConfigurationProfiles;
using Azure.Functions.Cli.ExtensionBundle;
using Azure.Functions.Cli.Extensions;
using Azure.Functions.Cli.Helpers;
Expand Down Expand Up @@ -33,12 +34,14 @@ internal class CreateFunctionAction : BaseAction
private IEnumerable<UserPrompt> _userPrompts;
private WorkerRuntime _workerRuntime;

public CreateFunctionAction(ITemplatesManager templatesManager, ISecretsManager secretsManager, IContextHelpManager contextHelpManager)
public CreateFunctionAction(ITemplatesManager templatesManager, ISecretsManager secretsManager, IContextHelpManager contextHelpManager, IEnumerable<IConfigurationProfile> configurationProfiles)
{
_templatesManager = templatesManager;
_secretsManager = secretsManager;
_contextHelpManager = contextHelpManager;
_initAction = new InitAction(_templatesManager, _secretsManager);

// Construct InitAction with the provided providers so it can validate and apply the profile
_initAction = new InitAction(_templatesManager, _secretsManager, configurationProfiles);
_userInputHandler = new UserInputHandler(_templatesManager);
}

Expand Down
52 changes: 51 additions & 1 deletion src/Cli/func/Actions/LocalActions/InitAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using Azure.Functions.Cli.Common;
using Azure.Functions.Cli.ConfigurationProfiles;
using Azure.Functions.Cli.Extensions;
using Azure.Functions.Cli.Helpers;
using Azure.Functions.Cli.Interfaces;
using Azure.Functions.Cli.StacksApi;
using Colors.Net;
using Fclp;
using Microsoft.Azure.WebJobs.Host.Config;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using static Azure.Functions.Cli.Common.OutputTheme;
Expand All @@ -24,15 +26,17 @@ internal class InitAction : BaseAction
private const string DefaultInProcTargetFramework = Common.TargetFramework.Net8;
private readonly ITemplatesManager _templatesManager;
private readonly ISecretsManager _secretsManager;
private readonly IEnumerable<IConfigurationProfile> _configurationProfiles;
internal static readonly Dictionary<Lazy<string>, Task<string>> FileToContentMap = new Dictionary<Lazy<string>, Task<string>>
{
{ new Lazy<string>(() => ".gitignore"), StaticResources.GitIgnore }
};

public InitAction(ITemplatesManager templatesManager, ISecretsManager secretsManager)
public InitAction(ITemplatesManager templatesManager, ISecretsManager secretsManager, IEnumerable<IConfigurationProfile> configurationProfiles)
{
_templatesManager = templatesManager;
_secretsManager = secretsManager;
_configurationProfiles = configurationProfiles;
}

public SourceControl SourceControl { get; set; } = SourceControl.Git;
Expand All @@ -59,6 +63,8 @@ public InitAction(ITemplatesManager templatesManager, ISecretsManager secretsMan

public string TargetFramework { get; set; }

public string ConfigurationProfileName { get; set; }

public bool? ManagedDependencies { get; set; }

public string ProgrammingModel { get; set; }
Expand Down Expand Up @@ -144,6 +150,13 @@ public override ICommandLineParserResult ParseArgs(string[] args)
.WithDescription("Do not create getting started documentation file. Currently supported when --worker-runtime set to python.")
.Callback(d => GeneratePythonDocumentation = !d);

Parser
.Setup<string>("configuration-profile")
.SetDefault(null)
.WithDescription(WarningColor("[preview]").ToString() + " Initialize a project with a host configuration profile. Currently supported: 'mcp-custom-handler'. "
+ WarningColor("Using a configuration profile may skip all other initialization steps.").ToString())
.Callback(cp => ConfigurationProfileName = cp);

if (args.Any() && !args.First().StartsWith("-"))
{
FolderName = args.First();
Expand Down Expand Up @@ -206,6 +219,12 @@ private async Task InitFunctionAppProject()
}
}

// If a configuration profile is provided, apply it and return
if (await TryApplyConfigurationProfileIfProvided())
{
return;
}

TelemetryHelpers.AddCommandEventToDictionary(TelemetryCommandEvents, "WorkerRuntime", ResolvedWorkerRuntime.ToString());

ValidateTargetFramework();
Expand All @@ -219,6 +238,7 @@ private async Task InitFunctionAppProject()
bool managedDependenciesOption = ResolveManagedDependencies(ResolvedWorkerRuntime, ManagedDependencies);
await InitLanguageSpecificArtifacts(ResolvedWorkerRuntime, ResolvedLanguage, ResolvedProgrammingModel, managedDependenciesOption, GeneratePythonDocumentation);
await WriteFiles();

await WriteHostJson(ResolvedWorkerRuntime, managedDependenciesOption, ExtensionBundle);
await WriteLocalSettingsJson(ResolvedWorkerRuntime, ResolvedProgrammingModel);
}
Expand Down Expand Up @@ -646,5 +666,35 @@ private async Task ShowEolMessage()
// ignore. Failure to show the EOL message should not fail the init command.
}
}

private async Task<bool> TryApplyConfigurationProfileIfProvided()
{
if (string.IsNullOrEmpty(ConfigurationProfileName))
{
return false;
}

IConfigurationProfile configurationProfile = _configurationProfiles
.FirstOrDefault(p => string.Equals(p.Name, ConfigurationProfileName, StringComparison.OrdinalIgnoreCase));

if (configurationProfile is null)
{
var supportedProfiles = _configurationProfiles
.Select(p => p.Name)
.ToList();

ColoredConsole.WriteLine(WarningColor($"Configuration profile '{ConfigurationProfileName}' is not supported. Supported values: {string.Join(", ", supportedProfiles)}"));

// Return true to avoid running the rest of the initialization steps, we are treating the use of `--configuration-profile`
// as a stand alone command. So if the provided profile is invalid, we just warn and exit.
return true;
}

// Apply the configuration profile and return
ColoredConsole.WriteLine(WarningColor($"You are using a preview feature. Configuration profiles may change in future releases."));
SetupProgressLogger.Section($"Applying configuration profile: {configurationProfile.Name}");
await configurationProfile.ApplyAsync(ResolvedWorkerRuntime, Force);
return true;
}
}
}
4 changes: 2 additions & 2 deletions src/Cli/func/Common/SecretsManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
using Azure.Functions.Cli.Interfaces;
using Colors.Net;
using Microsoft.Azure.WebJobs.Script;
using Microsoft.Build.Logging;
using static Colors.Net.StringStaticMethods;

namespace Azure.Functions.Cli.Common
{
Expand All @@ -27,7 +27,7 @@ public static string AppSettingsFilePath
});
var secretsFilePath = Path.Combine(rootPath, secretsFile);

ColoredConsole.WriteLine($"{secretsFile} found in root directory ({rootPath}).");
ColoredConsole.WriteLine(DarkGray($"'{secretsFile}' found in root directory ({rootPath})."));
return secretsFilePath;
}
}
Expand Down
22 changes: 22 additions & 0 deletions src/Cli/func/ConfigurationProfiles/IConfigurationProfile.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using Azure.Functions.Cli.Helpers;

namespace Azure.Functions.Cli.ConfigurationProfiles
{
internal interface IConfigurationProfile
{
/// <summary>
/// Gets the name of the profile.
/// </summary>
internal string Name { get; }

/// <summary>
/// Applies the profile by generating necessary configuration artifacts.
/// </summary>
/// <param name="runtime">The worker runtime of the function app.</param>
/// <param name="force">If true, forces overwriting existing configurations.</param>
internal Task ApplyAsync(WorkerRuntime runtime, bool force = false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using Azure.Functions.Cli.Common;
using Azure.Functions.Cli.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace Azure.Functions.Cli.ConfigurationProfiles
{
internal class McpCustomHandlerConfigurationProfile : IConfigurationProfile
{
// This feature flag enables MCP (Multi-Container Platform) support for custom handlers
// This flag is not required locally, but is required when deploying to Azure environments.
private const string McpFeatureFlag = "EnableMcpCustomHandlerPreview";

public string Name { get; } = "mcp-custom-handler";

public async Task ApplyAsync(WorkerRuntime workerRuntime, bool force = false)
{
await ApplyHostJsonAsync(force);
await ApplyLocalSettingsAsync(workerRuntime, force);
}

internal async Task ApplyHostJsonAsync(bool force)
{
string hostJsonPath = Path.Combine(Environment.CurrentDirectory, Constants.HostJsonFileName);
bool exists = FileSystemHelpers.FileExists(hostJsonPath);

// Load host json source: existing host.json or the static resource
string source = exists
? await FileSystemHelpers.ReadAllTextFromFileAsync(hostJsonPath)
: await StaticResources.HostJson;

var hostJsonObj = string.IsNullOrWhiteSpace(source) ? new JObject() : JObject.Parse(source);

// 1) Add configuration profile
bool updatedConfigProfile = UpsertIfMissing(hostJsonObj, "configurationProfile", JToken.FromObject(Name), force);
if (updatedConfigProfile)
{
SetupProgressLogger.Ok(Constants.HostJsonFileName, $"Set configuration profile to '{Name}'");
}

// 2) Add custom handler settings
var customHandlerJson = JObject.Parse(await StaticResources.CustomHandlerConfig);
bool updatedCustomHandler = UpsertIfMissing(hostJsonObj, "customHandler", customHandlerJson, force);
if (updatedCustomHandler)
{
SetupProgressLogger.Ok(Constants.HostJsonFileName, "Configured custom handler settings for MCP");
}

if (updatedConfigProfile || updatedCustomHandler)
{
string content = JsonConvert.SerializeObject(hostJsonObj, Formatting.Indented);
await FileSystemHelpers.WriteAllTextToFileAsync(hostJsonPath, content);

if (!exists)
{
SetupProgressLogger.FileCreated(Constants.HostJsonFileName, Path.GetFullPath(hostJsonPath));
}
}
else
{
SetupProgressLogger.Warn(Constants.HostJsonFileName, "Already configured (use --force to overwrite)");
}
}

internal async Task ApplyLocalSettingsAsync(WorkerRuntime workerRuntime, bool force)
{
string localSettingsPath = Path.Combine(Environment.CurrentDirectory, Constants.LocalSettingsJsonFileName);
bool exists = FileSystemHelpers.FileExists(localSettingsPath);

// Load source for local.settings.json: existing file or the static resource
string source = exists
? await FileSystemHelpers.ReadAllTextFromFileAsync(localSettingsPath)
: (await StaticResources.LocalSettingsJson)
.Replace($"{{{Constants.FunctionsWorkerRuntime}}}", WorkerRuntimeLanguageHelper.GetRuntimeMoniker(workerRuntime))
.Replace($"{{{Constants.AzureWebJobsStorage}}}", Constants.StorageEmulatorConnectionString);

var localSettingsObj = string.IsNullOrWhiteSpace(source) ? new JObject() : JObject.Parse(source);

var values = localSettingsObj["Values"] as JObject ?? new JObject();

// 1) Set worker runtime setting
bool updatedWorkerRuntime = UpsertIfMissing(
values,
Constants.FunctionsWorkerRuntime,
WorkerRuntimeLanguageHelper.GetRuntimeMoniker(workerRuntime),
force);

if (updatedWorkerRuntime)
{
SetupProgressLogger.Ok(Constants.LocalSettingsJsonFileName, $"Set {Constants.FunctionsWorkerRuntime} to '{WorkerRuntimeLanguageHelper.GetRuntimeMoniker(workerRuntime)}'");
}

// 2) Set feature flag setting
bool updatedFeatureFlag = false;
bool hasFlagsKey = values.TryGetValue(Constants.AzureWebJobsFeatureFlags, StringComparison.OrdinalIgnoreCase, out var flagsToken);
var flags = (flagsToken?.ToString() ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(f => f.Trim())
.Where(f => !string.IsNullOrWhiteSpace(f))
.ToList();

if (!flags.Contains(McpFeatureFlag, StringComparer.OrdinalIgnoreCase))
{
flags.Add(McpFeatureFlag);
values[Constants.AzureWebJobsFeatureFlags] = string.Join(",", flags);
updatedFeatureFlag = true;

if (!hasFlagsKey)
{
SetupProgressLogger.Ok(Constants.LocalSettingsJsonFileName, $"Added feature flag '{McpFeatureFlag}'");
}
else
{
SetupProgressLogger.Ok(Constants.LocalSettingsJsonFileName, $"Appended feature flag '{McpFeatureFlag}'");
}
}

if (updatedWorkerRuntime || updatedFeatureFlag)
{
localSettingsObj["Values"] = values;
string content = JsonConvert.SerializeObject(localSettingsObj, Formatting.Indented);
await FileSystemHelpers.WriteAllTextToFileAsync(localSettingsPath, content);

if (!exists)
{
SetupProgressLogger.FileCreated(Constants.LocalSettingsJsonFileName, localSettingsPath);
}
}
else
{
SetupProgressLogger.Warn(Constants.LocalSettingsJsonFileName, "Already configured (use --force to overwrite)");
}
}

private static bool UpsertIfMissing(JObject obj, string key, object desiredValue, bool forceSet)
{
JToken desired = JToken.FromObject(desiredValue);

if (obj.TryGetValue(key, StringComparison.OrdinalIgnoreCase, out var existing))
{
if (!forceSet)
{
return false;
}

obj[key] = desired;
return true;
}

obj[key] = desired;
return true;
}
}
}
Loading
Loading