Skip to content

Commit 279bae9

Browse files
committed
Address PR feedback & cleanup logging
1 parent d854168 commit 279bae9

File tree

7 files changed

+154
-120
lines changed

7 files changed

+154
-120
lines changed

release_notes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99

1010
- <entry>
1111
- Add Dockerfile for python 3.13 local build environment (#4611)
12+
- Implement preview feature to apply host configuration profiles on 'func init' command (#4675)

src/Cli/func/Actions/LocalActions/InitAction.cs

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using Azure.Functions.Cli.StacksApi;
1212
using Colors.Net;
1313
using Fclp;
14+
using Microsoft.Azure.WebJobs.Host.Config;
1415
using Newtonsoft.Json;
1516
using Newtonsoft.Json.Linq;
1617
using static Azure.Functions.Cli.Common.OutputTheme;
@@ -125,13 +126,6 @@ public override ICommandLineParserResult ParseArgs(string[] args)
125126
.WithDescription($"Initialize a project with the given target framework moniker. Currently supported only when --worker-runtime set to dotnet-isolated or dotnet. Options are - {string.Join(", ", TargetFrameworkHelper.GetSupportedTargetFrameworks())}")
126127
.Callback(tf => TargetFramework = tf);
127128

128-
Parser
129-
.Setup<string>("configurationProfile")
130-
.SetDefault(null)
131-
.WithDescription("Initialize the project with a configuration profile." +
132-
"Note that the Functions project will not be initialized and only the relevant configuration profile will be set. Currently supported: mcp-custom-handler (preview)")
133-
.Callback(cp => ConfigurationProfile = cp);
134-
135129
Parser
136130
.Setup<bool>("managed-dependencies")
137131
.WithDescription("Installs managed dependencies. Currently, only the PowerShell worker runtime supports this functionality.")
@@ -156,6 +150,13 @@ public override ICommandLineParserResult ParseArgs(string[] args)
156150
.WithDescription("Do not create getting started documentation file. Currently supported when --worker-runtime set to python.")
157151
.Callback(d => GeneratePythonDocumentation = !d);
158152

153+
Parser
154+
.Setup<string>("configuration-profile")
155+
.SetDefault(null)
156+
.WithDescription(WarningColor("[preview]").ToString() + " Initialize a project with a host configuration profile. Currently supported: 'mcp-custom-handler'. "
157+
+ WarningColor("Using a configuration profile may skip all other initialization steps.").ToString())
158+
.Callback(cp => ConfigurationProfile = cp);
159+
159160
if (args.Any() && !args.First().StartsWith("-"))
160161
{
161162
FolderName = args.First();
@@ -218,22 +219,9 @@ private async Task InitFunctionAppProject()
218219
}
219220
}
220221

221-
// Validate configuration profile if provided and ensure provider exists=
222-
if (!string.IsNullOrEmpty(ConfigurationProfile))
222+
// If a configuration profile is provided, apply it and return
223+
if (await ApplyConfigurationProfileIfProvided())
223224
{
224-
IConfigurationProfile selectedProvider = _configurationProfile.FirstOrDefault(p => p.Name == ConfigurationProfile);
225-
if (selectedProvider == null)
226-
{
227-
var allProfiles = _configurationProfile.Select(p => p.Name);
228-
var supportedProfileNames = string.Join(", ", allProfiles.Where(p => p != null));
229-
var supportedMessage = allProfiles.Any()
230-
? $"Supported values: {supportedProfileNames}."
231-
: "No configuration profiles are currently registered.";
232-
throw new CliArgumentsException($"configurationProfile '{ConfigurationProfile}' is not supported. {supportedMessage}");
233-
}
234-
235-
// Apply the configuration profile and return
236-
await selectedProvider.ApplyAsync(ResolvedWorkerRuntime, Force);
237225
return;
238226
}
239227

@@ -678,5 +666,32 @@ private async Task ShowEolMessage()
678666
// ignore. Failure to show the EOL message should not fail the init command.
679667
}
680668
}
669+
670+
private async Task<bool> ApplyConfigurationProfileIfProvided()
671+
{
672+
if (string.IsNullOrEmpty(ConfigurationProfile))
673+
{
674+
return false;
675+
}
676+
677+
IConfigurationProfile configurationProfile = _configurationProfile.FirstOrDefault(p => p.Name == ConfigurationProfile);
678+
if (configurationProfile == null)
679+
{
680+
var allProfiles = _configurationProfile.Select(p => p.Name);
681+
var supportedProfileNames = string.Join(", ", allProfiles.Where(p => p != null));
682+
var supportedMessage = allProfiles.Any()
683+
? $"Supported values: {supportedProfileNames}."
684+
: "No configuration profiles are currently registered.";
685+
686+
ColoredConsole.WriteLine(WarningColor($"Configuration profile '{ConfigurationProfile}' is not supported. {supportedMessage}"));
687+
return true;
688+
}
689+
690+
// Apply the configuration profile and return
691+
ColoredConsole.WriteLine(WarningColor($"You are using a preview feature. Configuration profiles may change in future releases."));
692+
SetupProgressLogger.Section($"Applying configuration profile: {configurationProfile.Name}");
693+
await configurationProfile.ApplyAsync(ResolvedWorkerRuntime, Force);
694+
return true;
695+
}
681696
}
682697
}

src/Cli/func/Common/SecretsManager.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
using Azure.Functions.Cli.Interfaces;
66
using Colors.Net;
77
using Microsoft.Azure.WebJobs.Script;
8-
using Microsoft.Build.Logging;
8+
using static Colors.Net.StringStaticMethods;
99

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

30-
ColoredConsole.WriteLine($"{secretsFile} found in root directory ({rootPath}).");
30+
ColoredConsole.WriteLine(DarkGray($"'{secretsFile}' found in root directory ({rootPath})."));
3131
return secretsFilePath;
3232
}
3333
}

src/Cli/func/ConfigurationProfiles/IConfigurationProfile.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ namespace Azure.Functions.Cli.ConfigurationProfiles
88
internal interface IConfigurationProfile
99
{
1010
/// <summary>
11-
/// Gets or sets the name of the profile.
11+
/// Gets the name of the profile.
1212
/// </summary>
13-
internal string Name { get; set; }
13+
internal string Name { get; }
1414

1515
/// <summary>
1616
/// Applies the profile by generating necessary configuration artifacts.

src/Cli/func/ConfigurationProfiles/McpCustomHandlerConfigurationProfile.cs

Lines changed: 51 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -3,71 +3,55 @@
33

44
using Azure.Functions.Cli.Common;
55
using Azure.Functions.Cli.Helpers;
6-
using Colors.Net;
76
using Newtonsoft.Json;
87
using Newtonsoft.Json.Linq;
98

109
namespace Azure.Functions.Cli.ConfigurationProfiles
1110
{
1211
internal class McpCustomHandlerConfigurationProfile : IConfigurationProfile
1312
{
14-
private static readonly WorkerRuntime[] _supportedRuntimes = new[]
15-
{
16-
WorkerRuntime.DotnetIsolated,
17-
WorkerRuntime.Python,
18-
WorkerRuntime.Node
19-
};
13+
// This feature flag enables MCP (Multi-Container Platform) support for custom handlers
14+
// This flag is not required locally, but is required when deploying to Azure environments.
15+
private const string McpFeatureFlag = "EnableMcpCustomHandlerPreview";
2016

21-
public string Name { get; set; } = "mcp-custom-handler";
17+
public string Name { get; } = "mcp-custom-handler";
2218

2319
public async Task ApplyAsync(WorkerRuntime workerRuntime, bool shouldForce = false)
2420
{
25-
ValidateWorkerRuntime(workerRuntime);
2621
await ApplyHostJsonAsync(shouldForce);
2722
await ApplyLocalSettingsAsync(workerRuntime, shouldForce);
2823
}
2924

30-
private static void ValidateWorkerRuntime(WorkerRuntime workerRuntime)
25+
public async Task ApplyHostJsonAsync(bool force)
3126
{
32-
if (!_supportedRuntimes.Contains(workerRuntime))
33-
{
34-
var supportedRuntimesList = string.Join(", ", _supportedRuntimes.Select(r => WorkerRuntimeLanguageHelper.GetRuntimeMoniker(r)));
35-
throw new CliException($"The MCP custom handler configuration profile only supports the following runtimes: {supportedRuntimesList}. " +
36-
$"The current runtime '{WorkerRuntimeLanguageHelper.GetRuntimeMoniker(workerRuntime)}' is not supported.");
37-
}
38-
}
27+
bool changed = false;
28+
string baseHostJson;
3929

40-
public async Task ApplyHostJsonAsync(bool shouldForce)
41-
{
4230
// Check if host.json exists and read it, otherwise use the default template
43-
var hostJsonPath = Path.Combine(Environment.CurrentDirectory, Constants.HostJsonFileName);
44-
var hostExists = FileSystemHelpers.FileExists(hostJsonPath);
45-
string baseHostJson;
31+
string hostJsonPath = Path.Combine(Environment.CurrentDirectory, Constants.HostJsonFileName);
4632

47-
JObject hostJsonObj;
48-
if (hostExists)
33+
if (FileSystemHelpers.FileExists(hostJsonPath))
4934
{
50-
ColoredConsole.WriteLine($"Applying MCP custom handler configuration profile to existing {hostJsonPath}...");
35+
SetupProgressLogger.FileFound("host.json", hostJsonPath);
5136
baseHostJson = await FileSystemHelpers.ReadAllTextFromFileAsync(hostJsonPath);
5237
}
5338
else
5439
{
40+
SetupProgressLogger.FileCreated("host.json", hostJsonPath);
5541
baseHostJson = await StaticResources.HostJson;
5642
}
5743

58-
hostJsonObj = JsonConvert.DeserializeObject<JObject>(baseHostJson);
59-
60-
var changed = false;
44+
JObject hostJsonObj = JsonConvert.DeserializeObject<JObject>(baseHostJson);
6145

62-
// Add configurationProfile if missing or if shouldForce is true
63-
if (!hostJsonObj.TryGetValue("configurationProfile", StringComparison.OrdinalIgnoreCase, out _) || shouldForce)
46+
// Add configurationProfile if missing or if force is true
47+
if (!hostJsonObj.TryGetValue("configurationProfile", StringComparison.OrdinalIgnoreCase, out _) || force)
6448
{
65-
hostJsonObj["configurationProfile"] = "mcp-custom-handler";
49+
hostJsonObj["configurationProfile"] = Name;
6650
changed = true;
6751
}
6852

69-
// Add customHandler section if missing or if shouldForce is true
70-
if (!hostJsonObj.TryGetValue("customHandler", StringComparison.OrdinalIgnoreCase, out _) || shouldForce)
53+
// Add customHandler section if missing or if force is true
54+
if (!hostJsonObj.TryGetValue("customHandler", StringComparison.OrdinalIgnoreCase, out _) || force)
7155
{
7256
hostJsonObj["customHandler"] = new JObject
7357
{
@@ -82,58 +66,55 @@ public async Task ApplyHostJsonAsync(bool shouldForce)
8266

8367
if (changed)
8468
{
85-
var hostJsonContent = JsonConvert.SerializeObject(hostJsonObj, Formatting.Indented);
69+
string hostJsonContent = JsonConvert.SerializeObject(hostJsonObj, Formatting.Indented);
8670
await FileSystemHelpers.WriteAllTextToFileAsync(hostJsonPath, hostJsonContent);
71+
SetupProgressLogger.Ok("host.json", "Updated with MCP configuration profile");
72+
}
73+
else
74+
{
75+
SetupProgressLogger.Warn("host.json", "Already configured (use --force to overwrite)");
8776
}
88-
89-
ColoredConsole.WriteLine(changed
90-
? "Updated host.json with MCP configuration profile."
91-
: "host.json already contains MCP configuration profile. Please pass in `--force` to overwrite.\n");
9277
}
9378

94-
public async Task ApplyLocalSettingsAsync(WorkerRuntime workerRuntime, bool shouldForce)
79+
public async Task ApplyLocalSettingsAsync(WorkerRuntime workerRuntime, bool force)
9580
{
96-
// Check if local.settings.json exists and read it, otherwise use the default template
97-
var localSettingsPath = Path.Combine(Environment.CurrentDirectory, "local.settings.json");
98-
var localExists = FileSystemHelpers.FileExists(localSettingsPath);
81+
bool changed = false;
9982
string baseLocalSettings;
10083

101-
JObject localObj;
102-
if (localExists)
84+
// Check if local.settings.json exists and read it, otherwise use the default template
85+
string localSettingsPath = Path.Combine(Environment.CurrentDirectory, "local.settings.json");
86+
87+
if (FileSystemHelpers.FileExists(localSettingsPath))
10388
{
104-
ColoredConsole.WriteLine($"Applying MCP custom handler configuration profile to existing {localSettingsPath}...");
89+
SetupProgressLogger.FileFound("local.settings.json", localSettingsPath);
10590
baseLocalSettings = await FileSystemHelpers.ReadAllTextFromFileAsync(localSettingsPath);
10691
}
10792
else
10893
{
94+
SetupProgressLogger.FileCreated("local.settings.json", localSettingsPath);
10995
baseLocalSettings = await StaticResources.LocalSettingsJson;
11096

11197
// Replace placeholders in the template
11298
baseLocalSettings = baseLocalSettings.Replace($"{{{Constants.FunctionsWorkerRuntime}}}", WorkerRuntimeLanguageHelper.GetRuntimeMoniker(workerRuntime));
11399
baseLocalSettings = baseLocalSettings.Replace($"{{{Constants.AzureWebJobsStorage}}}", Constants.StorageEmulatorConnectionString);
114100
}
115101

116-
localObj = JsonConvert.DeserializeObject<JObject>(baseLocalSettings);
117-
118-
var changed = false;
119-
var values = localObj["Values"] as JObject ?? new JObject();
120-
var runtimeKey = Constants.FunctionsWorkerRuntime;
102+
JObject localObj = JsonConvert.DeserializeObject<JObject>(baseLocalSettings);
103+
JObject values = localObj["Values"] as JObject ?? [];
121104

122-
// Determine moniker for default; if existing runtime present, do not overwrite unless shouldForce is true
123-
if (!values.TryGetValue(runtimeKey, StringComparison.OrdinalIgnoreCase, out _) || shouldForce)
105+
// Determine moniker for default; if existing runtime present, do not overwrite unless force is true
106+
if (!values.TryGetValue(Constants.FunctionsWorkerRuntime, StringComparison.OrdinalIgnoreCase, out _) || force)
124107
{
125-
var runtimeMoniker = WorkerRuntimeLanguageHelper.GetRuntimeMoniker(workerRuntime);
126-
127-
values[runtimeKey] = runtimeMoniker;
108+
string runtimeMoniker = WorkerRuntimeLanguageHelper.GetRuntimeMoniker(workerRuntime);
109+
values[Constants.FunctionsWorkerRuntime] = runtimeMoniker;
128110
changed = true;
129111
}
130112

131-
// Handle AzureWebJobsFeatureFlags - append if exists and shouldForce is enabled, create if not
132-
const string mcpFeatureFlag = "EnableMcpCustomHandlerPreview";
133-
var azureWebJobsFeatureFlagsExists = values.TryGetValue("AzureWebJobsFeatureFlags", StringComparison.OrdinalIgnoreCase, out var existingFlagsToken);
134-
if (azureWebJobsFeatureFlagsExists && shouldForce)
113+
// Handle AzureWebJobsFeatureFlags - append if exists and force is enabled, create if not
114+
bool azureWebJobsFeatureFlagsExists = values.TryGetValue(Constants.AzureWebJobsFeatureFlags, StringComparison.OrdinalIgnoreCase, out var existingFlagsToken);
115+
if (azureWebJobsFeatureFlagsExists && force)
135116
{
136-
var existingFlags = existingFlagsToken?.ToString() ?? string.Empty;
117+
string existingFlags = existingFlagsToken?.ToString() ?? string.Empty;
137118

138119
// Split by comma and trim whitespace
139120
var flagsList = existingFlags
@@ -143,9 +124,9 @@ public async Task ApplyLocalSettingsAsync(WorkerRuntime workerRuntime, bool shou
143124
.ToList();
144125

145126
// Add the MCP feature flag if it's not already present
146-
if (!flagsList.Contains(mcpFeatureFlag, StringComparer.OrdinalIgnoreCase))
127+
if (!flagsList.Contains(McpFeatureFlag, StringComparer.OrdinalIgnoreCase))
147128
{
148-
flagsList.Add(mcpFeatureFlag);
129+
flagsList.Add(McpFeatureFlag);
149130

150131
// Rejoin with comma and space
151132
values["AzureWebJobsFeatureFlags"] = string.Join(",", flagsList);
@@ -155,20 +136,22 @@ public async Task ApplyLocalSettingsAsync(WorkerRuntime workerRuntime, bool shou
155136
else if (!azureWebJobsFeatureFlagsExists)
156137
{
157138
// No existing feature flags, create with just our flag
158-
values["AzureWebJobsFeatureFlags"] = mcpFeatureFlag;
139+
values["AzureWebJobsFeatureFlags"] = McpFeatureFlag;
159140
changed = true;
141+
SetupProgressLogger.Warn("local.settings.json", $"Added feature flag '{McpFeatureFlag}'");
160142
}
161143

162144
if (changed)
163145
{
164146
localObj["Values"] = values;
165-
var localContent = JsonConvert.SerializeObject(localObj, Formatting.Indented);
147+
string localContent = JsonConvert.SerializeObject(localObj, Formatting.Indented);
166148
await FileSystemHelpers.WriteAllTextToFileAsync(localSettingsPath, localContent);
149+
SetupProgressLogger.Ok("local.settings.json", "Updated settings");
150+
}
151+
else
152+
{
153+
SetupProgressLogger.Warn("local.settings.json", "Already configured (use --force to overwrite)");
167154
}
168-
169-
ColoredConsole.WriteLine(changed
170-
? "Updated local.settings.json with MCP configuration profile."
171-
: "local.settings.json already contains MCP configuration profile. Please pass in `--force` to overwrite.\n");
172155
}
173156
}
174157
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE in the project root for license information.
3+
4+
using System.Text;
5+
using Colors.Net;
6+
using static Colors.Net.StringStaticMethods;
7+
8+
namespace Azure.Functions.Cli.Helpers
9+
{
10+
internal static class SetupProgressLogger
11+
{
12+
static SetupProgressLogger()
13+
{
14+
try
15+
{
16+
Console.OutputEncoding = Encoding.UTF8;
17+
}
18+
catch
19+
{
20+
/* ignore */
21+
}
22+
}
23+
24+
private static string Rel(string path)
25+
{
26+
string cwd = Environment.CurrentDirectory.TrimEnd(Path.DirectorySeparatorChar);
27+
return path.StartsWith(cwd, StringComparison.OrdinalIgnoreCase)
28+
? string.Concat(".", path.AsSpan(cwd.Length))
29+
: path;
30+
}
31+
32+
public static void Section(string text) =>
33+
ColoredConsole.WriteLine($"\n{DarkGray($"▸ {text}")}");
34+
35+
public static void Step(string scope, string text) =>
36+
ColoredConsole.WriteLine($"{Gray($"[{scope}]")} {text}");
37+
38+
public static void Ok(string scope, string text) =>
39+
ColoredConsole.WriteLine($"{Gray($"[{scope}]")} {Green($"✓ {text}")}");
40+
41+
public static void Info(string scope, string text) =>
42+
ColoredConsole.WriteLine($"{Gray($"[{scope}]")} {text}");
43+
44+
public static void Warn(string scope, string text) =>
45+
ColoredConsole.WriteLine($"{Gray($"[{scope}]")} {Yellow($"⚠ {text}")}");
46+
47+
public static void FileFound(string scope, string path) =>
48+
Step(scope, $"Found at {Rel(path)}");
49+
50+
public static void FileCreated(string scope, string path) =>
51+
Step(scope, $"Creating new at {Rel(path)}");
52+
}
53+
}

0 commit comments

Comments
 (0)