Skip to content

Commit ea33b41

Browse files
authored
Fix .NET runtime version mismatched (.NET 9 support, safer runtime detection)) (#130)
* Fix .NET runtime version mismatched * Resolve suggested comments * Resolve more comments
1 parent c27deb2 commit ea33b41

File tree

6 files changed

+315
-17
lines changed

6 files changed

+315
-17
lines changed

src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ public static Command CreateCommand(
141141
if (!string.IsNullOrWhiteSpace(dryRunConfig.DeploymentProjectPath))
142142
{
143143
var detectedPlatform = platformDetector.Detect(dryRunConfig.DeploymentProjectPath);
144-
var detectedRuntime = GetRuntimeForPlatform(detectedPlatform);
144+
var detectedRuntime = GetRuntimeForPlatform(detectedPlatform, dryRunConfig.DeploymentProjectPath, executor, logger);
145145
logger.LogInformation(" - Detected Platform: {Platform}", detectedPlatform);
146146
logger.LogInformation(" - Runtime: {Runtime}", detectedRuntime);
147147
}
@@ -301,6 +301,7 @@ await CreateInfrastructureAsync(
301301
planSku,
302302
webAppName,
303303
generatedConfigPath,
304+
deploymentProjectPath,
304305
platform,
305306
logger,
306307
needDeployment,
@@ -411,6 +412,7 @@ public static async Task CreateInfrastructureAsync(
411412
string? planSku,
412413
string webAppName,
413414
string generatedConfigPath,
415+
string deploymentProjectPath,
414416
Models.ProjectPlatform platform,
415417
ILogger logger,
416418
bool needDeployment,
@@ -496,7 +498,7 @@ public static async Task CreateInfrastructureAsync(
496498
var webShow = await executor.ExecuteAsync("az", $"webapp show -g {resourceGroup} -n {webAppName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true);
497499
if (!webShow.Success)
498500
{
499-
var runtime = GetRuntimeForPlatform(platform);
501+
var runtime = GetRuntimeForPlatform(platform, deploymentProjectPath, executor, logger);
500502
logger.LogInformation("Creating web app {App} with runtime {Runtime}", webAppName, runtime);
501503
var createResult = await executor.ExecuteAsync("az", $"webapp create -g {resourceGroup} -p {planName} -n {webAppName} --runtime \"{runtime}\" --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true);
502504
if (!createResult.Success)
@@ -552,7 +554,7 @@ public static async Task CreateInfrastructureAsync(
552554
}
553555
else
554556
{
555-
var linuxFxVersion = GetLinuxFxVersionForPlatform(platform);
557+
var linuxFxVersion = GetLinuxFxVersionForPlatform(platform, deploymentProjectPath, executor, logger);
556558
logger.LogInformation("Web app already exists: {App} (skipping creation)", webAppName);
557559
logger.LogInformation("Configuring web app to use {Platform} runtime ({LinuxFxVersion})...", platform, linuxFxVersion);
558560
await AzWarnAsync(executor, logger, $"webapp config set -g {resourceGroup} -n {webAppName} --linux-fx-version \"{linuxFxVersion}\" --subscription {subscriptionId}", "Configure runtime");
@@ -818,8 +820,14 @@ internal static async Task EnsureAppServicePlanExistsAsync(
818820
/// Get the Azure Web App runtime string based on the detected platform
819821
/// (from A365SetupRunner GetRuntimeForPlatform method)
820822
/// </summary>
821-
private static string GetRuntimeForPlatform(Models.ProjectPlatform platform)
823+
private static string GetRuntimeForPlatform(Models.ProjectPlatform platform, string? deploymentProjectPath, CommandExecutor executor, ILogger logger)
822824
{
825+
var dotnetVersion = ResolveDotNetRuntimeVersion(platform, deploymentProjectPath, executor, logger);
826+
if (!string.IsNullOrWhiteSpace(dotnetVersion))
827+
{
828+
return $"DOTNETCORE:{dotnetVersion}";
829+
}
830+
823831
return platform switch
824832
{
825833
Models.ProjectPlatform.Python => "PYTHON:3.11",
@@ -833,8 +841,14 @@ private static string GetRuntimeForPlatform(Models.ProjectPlatform platform)
833841
/// Get the Azure Web App Linux FX Version string based on the detected platform
834842
/// (from A365SetupRunner GetLinuxFxVersionForPlatform method)
835843
/// </summary>
836-
private static string GetLinuxFxVersionForPlatform(Models.ProjectPlatform platform)
844+
private static string GetLinuxFxVersionForPlatform(Models.ProjectPlatform platform, string? deploymentProjectPath, CommandExecutor executor, ILogger logger)
837845
{
846+
var dotnetVersion = ResolveDotNetRuntimeVersion(platform, deploymentProjectPath, executor, logger);
847+
if (!string.IsNullOrWhiteSpace(dotnetVersion))
848+
{
849+
return $"DOTNETCORE:{dotnetVersion}";
850+
}
851+
838852
return platform switch
839853
{
840854
Models.ProjectPlatform.Python => "PYTHON|3.11",
@@ -844,6 +858,51 @@ private static string GetLinuxFxVersionForPlatform(Models.ProjectPlatform platfo
844858
};
845859
}
846860

861+
private static string? ResolveDotNetRuntimeVersion(
862+
Models.ProjectPlatform platform,
863+
string? deploymentProjectPath,
864+
CommandExecutor executor,
865+
ILogger logger)
866+
{
867+
if (platform != Models.ProjectPlatform.DotNet ||
868+
string.IsNullOrWhiteSpace(deploymentProjectPath))
869+
{
870+
return null;
871+
}
872+
873+
var csproj = Directory
874+
.GetFiles(deploymentProjectPath, "*.csproj", SearchOption.TopDirectoryOnly)
875+
.FirstOrDefault();
876+
if (csproj == null)
877+
{
878+
logger.LogWarning("No .csproj file found in deploymentProjectPath: {Path}", deploymentProjectPath);
879+
return null;
880+
}
881+
882+
var version = DotNetProjectHelper.DetectTargetRuntimeVersion(csproj, logger);
883+
if (string.IsNullOrWhiteSpace(version))
884+
{
885+
logger.LogWarning("Unable to detect TargetFramework version from {Project}", csproj);
886+
return null;
887+
}
888+
889+
// Validate local SDK
890+
var sdkResult = executor.ExecuteAsync("dotnet", "--version", captureOutput: true).GetAwaiter().GetResult();
891+
var installedVersion = sdkResult.Success ? sdkResult.StandardOutput.Trim() : null;
892+
893+
if (!sdkResult.Success ||
894+
string.IsNullOrWhiteSpace(installedVersion) ||
895+
!installedVersion.StartsWith(version, StringComparison.Ordinal))
896+
{
897+
throw new DotNetSdkVersionMismatchException(
898+
requiredVersion: version,
899+
installedVersion: installedVersion,
900+
projectFilePath: csproj);
901+
}
902+
903+
return version; // e.g. "8.0", "9.0"
904+
}
905+
847906
private static string Short(string? text)
848907
=> string.IsNullOrWhiteSpace(text) ? string.Empty : (text.Length <= 180 ? text.Trim() : text[..177] + "...");
849908

src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,6 @@ public static class ErrorCodes
2828
public const string MosServicePrincipalCreationFailed = "MOS_SERVICE_PRINCIPAL_CREATION_FAILED";
2929
public const string MosInsufficientPrivileges = "MOS_INSUFFICIENT_PRIVILEGES";
3030
public const string MosPermissionUpdateFailed = "MOS_PERMISSION_UPDATE_FAILED";
31+
public const string DotNetSdkVersionMismatch = "DOTNET_SDK_VERSION_MISMATCH";
3132
}
3233
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using Microsoft.Agents.A365.DevTools.Cli.Constants;
5+
6+
namespace Microsoft.Agents.A365.DevTools.Cli.Exceptions;
7+
8+
/// <summary>
9+
/// Thrown when there is a .NET SDK version mismatch.
10+
/// </summary>
11+
public sealed class DotNetSdkVersionMismatchException : Agent365Exception
12+
{
13+
public override bool IsUserError => true;
14+
public override int ExitCode => 1;
15+
16+
public DotNetSdkVersionMismatchException(
17+
string requiredVersion,
18+
string? installedVersion,
19+
string projectFilePath)
20+
: base(
21+
errorCode: ErrorCodes.DotNetSdkVersionMismatch,
22+
issueDescription: $"The project targets .NET {requiredVersion}, but the required .NET SDK is not installed.",
23+
errorDetails: new List<string>
24+
{
25+
$"Project file: {projectFilePath}",
26+
$"TargetFramework: net{requiredVersion}",
27+
$"Installed SDK version: {installedVersion ?? "Not found"}"
28+
},
29+
mitigationSteps: new List<string>
30+
{
31+
$"Install the .NET {requiredVersion} SDK from https://dotnet.microsoft.com/download",
32+
"Restart your terminal after installation",
33+
"Re-run the a365 deploy command"
34+
},
35+
context: new Dictionary<string, string>
36+
{
37+
["TargetDotNetVersion"] = requiredVersion,
38+
["ProjectFile"] = projectFilePath
39+
})
40+
{
41+
}
42+
}

src/Microsoft.Agents.A365.DevTools.Cli/Services/DotNetBuilder.cs

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ public async Task<string> BuildAsync(string projectDir, string outputPath, bool
100100
return publishPath;
101101
}
102102

103-
public async Task<OryxManifest> CreateManifestAsync(string projectDir, string publishPath)
103+
public Task<OryxManifest> CreateManifestAsync(string projectDir, string publishPath)
104104
{
105105
_logger.LogInformation("Creating Oryx manifest for .NET...");
106106

@@ -115,29 +115,24 @@ public async Task<OryxManifest> CreateManifestAsync(string projectDir, string pu
115115
_logger.LogInformation("Detected entry point: {Dll}", entryDll);
116116

117117
// Detect .NET version
118-
var dotnetVersion = "8.0"; // Default
118+
var dotnetVersion = "8.0"; // Default fallback
119119
var projectFile = ResolveProjectFile(projectDir);
120120
if (projectFile != null)
121121
{
122122
var projectFilePath = Path.Combine(projectDir, projectFile);
123-
var projectContent = await File.ReadAllTextAsync(projectFilePath);
124-
var tfmMatch = System.Text.RegularExpressions.Regex.Match(
125-
projectContent,
126-
@"<TargetFramework>net(\d+\.\d+)</TargetFramework>");
127-
128-
if (tfmMatch.Success)
123+
var detected = DotNetProjectHelper.DetectTargetRuntimeVersion(projectFilePath, _logger);
124+
if (!string.IsNullOrWhiteSpace(detected))
129125
{
130-
dotnetVersion = tfmMatch.Groups[1].Value;
131-
_logger.LogInformation("Detected .NET version: {Version}", dotnetVersion);
126+
dotnetVersion = detected;
132127
}
133128
}
134129

135-
return new OryxManifest
130+
return Task.FromResult(new OryxManifest
136131
{
137132
Platform = "dotnet",
138133
Version = dotnetVersion,
139134
Command = $"dotnet {entryDll}"
140-
};
135+
});
141136
}
142137

143138
public async Task<bool> ConvertEnvToAzureAppSettingsAsync(string projectDir, string resourceGroup, string webAppName, bool verbose)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using Microsoft.Extensions.Logging;
5+
using System.Text.RegularExpressions;
6+
7+
namespace Microsoft.Agents.A365.DevTools.Cli.Services.Helpers;
8+
9+
/// <summary>
10+
/// Helper class for inspecting .NET project metadata.
11+
/// </summary>
12+
public static class DotNetProjectHelper
13+
{
14+
/// <summary>
15+
/// Detects the target .NET runtime version (e.g. "8.0", "9.0") from a .csproj file.
16+
/// Supports both TargetFramework and TargetFrameworks.
17+
/// </summary>
18+
/// <param name="projectFilePath">The full path to the .csproj file</param>
19+
/// <param name="logger">Logger for diagnostic messages</param>
20+
/// <returns>
21+
/// The detected .NET version (e.g., "8.0", "9.0"), or null if:
22+
/// - The file doesn't exist
23+
/// - No TargetFramework element is found
24+
/// - The TFM format is not recognized (only supports "netX.Y" format)
25+
/// When multiple TFMs are specified, returns the first one.
26+
/// It does NOT support legacy or library-only TFMs and Unsupported TFMs return null and fall back to default runtime selection.
27+
/// </returns>
28+
public static string? DetectTargetRuntimeVersion(string projectFilePath, ILogger logger)
29+
{
30+
if (!File.Exists(projectFilePath))
31+
{
32+
logger.LogWarning("Project file not found: {Path}", projectFilePath);
33+
return null;
34+
}
35+
36+
var content = File.ReadAllText(projectFilePath);
37+
38+
// Match <TargetFramework> or <TargetFrameworks>
39+
var tfmMatch = Regex.Match(
40+
content,
41+
@"<TargetFrameworks?>\s*([^<]+)\s*</TargetFrameworks?>",
42+
RegexOptions.IgnoreCase);
43+
44+
if (!tfmMatch.Success)
45+
{
46+
logger.LogWarning("No TargetFramework(s) found in project file: {Path}", projectFilePath);
47+
return null;
48+
}
49+
50+
// If multiple TFMs are specified, pick the first one
51+
// (future improvement: pick highest)
52+
var tfms = tfmMatch.Groups[1].Value
53+
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
54+
55+
var tfm = tfms.FirstOrDefault();
56+
if (string.IsNullOrWhiteSpace(tfm))
57+
{
58+
return null;
59+
}
60+
61+
// Match net8.0, net9.0, net9.0-windows, etc.
62+
var verMatch = Regex.Match(
63+
tfm,
64+
@"net(\d+)\.(\d+)",
65+
RegexOptions.IgnoreCase);
66+
67+
if (!verMatch.Success)
68+
{
69+
logger.LogWarning("Unrecognized TargetFramework format: {Tfm}", tfm);
70+
return null;
71+
}
72+
73+
var version = $"{verMatch.Groups[1].Value}.{verMatch.Groups[2].Value}";
74+
logger.LogInformation(
75+
"Detected TargetFramework: {Tfm} → .NET {Version}",
76+
tfm,
77+
version);
78+
79+
return version;
80+
}
81+
}

0 commit comments

Comments
 (0)