Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ public static Command CreateCommand(
if (!string.IsNullOrWhiteSpace(dryRunConfig.DeploymentProjectPath))
{
var detectedPlatform = platformDetector.Detect(dryRunConfig.DeploymentProjectPath);
var detectedRuntime = GetRuntimeForPlatform(detectedPlatform);
var detectedRuntime = GetRuntimeForPlatform(detectedPlatform, dryRunConfig.DeploymentProjectPath, executor, logger);
logger.LogInformation(" - Detected Platform: {Platform}", detectedPlatform);
logger.LogInformation(" - Runtime: {Runtime}", detectedRuntime);
}
Expand Down Expand Up @@ -301,6 +301,7 @@ await CreateInfrastructureAsync(
planSku,
webAppName,
generatedConfigPath,
deploymentProjectPath,
platform,
logger,
needDeployment,
Expand Down Expand Up @@ -411,6 +412,7 @@ public static async Task CreateInfrastructureAsync(
string? planSku,
string webAppName,
string generatedConfigPath,
string deploymentProjectPath,
Models.ProjectPlatform platform,
ILogger logger,
bool needDeployment,
Expand Down Expand Up @@ -496,7 +498,7 @@ public static async Task CreateInfrastructureAsync(
var webShow = await executor.ExecuteAsync("az", $"webapp show -g {resourceGroup} -n {webAppName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true);
if (!webShow.Success)
{
var runtime = GetRuntimeForPlatform(platform);
var runtime = GetRuntimeForPlatform(platform, deploymentProjectPath, executor, logger);
logger.LogInformation("Creating web app {App} with runtime {Runtime}", webAppName, runtime);
var createResult = await executor.ExecuteAsync("az", $"webapp create -g {resourceGroup} -p {planName} -n {webAppName} --runtime \"{runtime}\" --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true);
if (!createResult.Success)
Expand Down Expand Up @@ -552,7 +554,7 @@ public static async Task CreateInfrastructureAsync(
}
else
{
var linuxFxVersion = GetLinuxFxVersionForPlatform(platform);
var linuxFxVersion = GetLinuxFxVersionForPlatform(platform, deploymentProjectPath, executor, logger);
logger.LogInformation("Web app already exists: {App} (skipping creation)", webAppName);
logger.LogInformation("Configuring web app to use {Platform} runtime ({LinuxFxVersion})...", platform, linuxFxVersion);
await AzWarnAsync(executor, logger, $"webapp config set -g {resourceGroup} -n {webAppName} --linux-fx-version \"{linuxFxVersion}\" --subscription {subscriptionId}", "Configure runtime");
Expand Down Expand Up @@ -818,8 +820,14 @@ internal static async Task EnsureAppServicePlanExistsAsync(
/// Get the Azure Web App runtime string based on the detected platform
/// (from A365SetupRunner GetRuntimeForPlatform method)
/// </summary>
private static string GetRuntimeForPlatform(Models.ProjectPlatform platform)
private static string GetRuntimeForPlatform(Models.ProjectPlatform platform, string? deploymentProjectPath, CommandExecutor executor, ILogger logger)
{
var dotnetVersion = ResolveDotNetRuntimeVersion(platform, deploymentProjectPath, executor, logger);
if (!string.IsNullOrWhiteSpace(dotnetVersion))
{
return $"DOTNETCORE:{dotnetVersion}";
}

return platform switch
{
Models.ProjectPlatform.Python => "PYTHON:3.11",
Expand All @@ -833,8 +841,14 @@ private static string GetRuntimeForPlatform(Models.ProjectPlatform platform)
/// Get the Azure Web App Linux FX Version string based on the detected platform
/// (from A365SetupRunner GetLinuxFxVersionForPlatform method)
/// </summary>
private static string GetLinuxFxVersionForPlatform(Models.ProjectPlatform platform)
private static string GetLinuxFxVersionForPlatform(Models.ProjectPlatform platform, string? deploymentProjectPath, CommandExecutor executor, ILogger logger)
{
var dotnetVersion = ResolveDotNetRuntimeVersion(platform, deploymentProjectPath, executor, logger);
if (!string.IsNullOrWhiteSpace(dotnetVersion))
{
return $"DOTNETCORE:{dotnetVersion}";
}

return platform switch
{
Models.ProjectPlatform.Python => "PYTHON|3.11",
Expand All @@ -844,6 +858,51 @@ private static string GetLinuxFxVersionForPlatform(Models.ProjectPlatform platfo
};
}

private static string? ResolveDotNetRuntimeVersion(
Models.ProjectPlatform platform,
string? deploymentProjectPath,
CommandExecutor executor,
ILogger logger)
{
if (platform != Models.ProjectPlatform.DotNet ||
string.IsNullOrWhiteSpace(deploymentProjectPath))
{
return null;
}

var csproj = Directory
.GetFiles(deploymentProjectPath, "*.csproj", SearchOption.TopDirectoryOnly)
.FirstOrDefault();
if (csproj == null)
{
logger.LogWarning("No .csproj file found in deploymentProjectPath: {Path}", deploymentProjectPath);
return null;
}

var version = DotNetProjectHelper.DetectTargetRuntimeVersion(csproj, logger);
if (string.IsNullOrWhiteSpace(version))
{
logger.LogWarning("Unable to detect TargetFramework version from {Project}", csproj);
return null;
}

// Validate local SDK
var sdkResult = executor.ExecuteAsync("dotnet", "--version", captureOutput: true).GetAwaiter().GetResult();
var installedVersion = sdkResult.Success ? sdkResult.StandardOutput.Trim() : null;

if (!sdkResult.Success ||
string.IsNullOrWhiteSpace(installedVersion) ||
!installedVersion.StartsWith(version, StringComparison.Ordinal))
{
throw new DotNetSdkVersionMismatchException(
requiredVersion: version,
installedVersion: installedVersion,
projectFilePath: csproj);
}

return version; // e.g. "8.0", "9.0"
}

private static string Short(string? text)
=> string.IsNullOrWhiteSpace(text) ? string.Empty : (text.Length <= 180 ? text.Trim() : text[..177] + "...");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,6 @@ public static class ErrorCodes
public const string MosServicePrincipalCreationFailed = "MOS_SERVICE_PRINCIPAL_CREATION_FAILED";
public const string MosInsufficientPrivileges = "MOS_INSUFFICIENT_PRIVILEGES";
public const string MosPermissionUpdateFailed = "MOS_PERMISSION_UPDATE_FAILED";
public const string DotNetSdkVersionMismatch = "DOTNET_SDK_VERSION_MISMATCH";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Agents.A365.DevTools.Cli.Constants;

namespace Microsoft.Agents.A365.DevTools.Cli.Exceptions;

/// <summary>
/// Thrown when there is a .NET SDK version mismatch.
/// </summary>
public sealed class DotNetSdkVersionMismatchException : Agent365Exception
{
public override bool IsUserError => true;
public override int ExitCode => 1;

public DotNetSdkVersionMismatchException(
string requiredVersion,
string? installedVersion,
string projectFilePath)
: base(
errorCode: ErrorCodes.DotNetSdkVersionMismatch,
issueDescription: $"The project targets .NET {requiredVersion}, but the required .NET SDK is not installed.",
errorDetails: new List<string>
{
$"Project file: {projectFilePath}",
$"TargetFramework: net{requiredVersion}",
$"Installed SDK version: {installedVersion ?? "Not found"}"
},
mitigationSteps: new List<string>
{
$"Install the .NET {requiredVersion} SDK from https://dotnet.microsoft.com/download",
"Restart your terminal after installation",
"Re-run the a365 deploy command"
},
context: new Dictionary<string, string>
{
["TargetDotNetVersion"] = requiredVersion,
["ProjectFile"] = projectFilePath
})
{
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ public async Task<string> BuildAsync(string projectDir, string outputPath, bool
return publishPath;
}

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

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

// Detect .NET version
var dotnetVersion = "8.0"; // Default
var dotnetVersion = "8.0"; // Default fallback
var projectFile = ResolveProjectFile(projectDir);
if (projectFile != null)
{
var projectFilePath = Path.Combine(projectDir, projectFile);
var projectContent = await File.ReadAllTextAsync(projectFilePath);
var tfmMatch = System.Text.RegularExpressions.Regex.Match(
projectContent,
@"<TargetFramework>net(\d+\.\d+)</TargetFramework>");

if (tfmMatch.Success)
var detected = DotNetProjectHelper.DetectTargetRuntimeVersion(projectFilePath, _logger);
if (!string.IsNullOrWhiteSpace(detected))
{
dotnetVersion = tfmMatch.Groups[1].Value;
_logger.LogInformation("Detected .NET version: {Version}", dotnetVersion);
dotnetVersion = detected;
}
}

return new OryxManifest
return Task.FromResult(new OryxManifest
{
Platform = "dotnet",
Version = dotnetVersion,
Command = $"dotnet {entryDll}"
};
});
}

public async Task<bool> ConvertEnvToAzureAppSettingsAsync(string projectDir, string resourceGroup, string webAppName, bool verbose)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Extensions.Logging;
using System.Text.RegularExpressions;

namespace Microsoft.Agents.A365.DevTools.Cli.Services.Helpers;

/// <summary>
/// Helper class for inspecting .NET project metadata.
/// </summary>
public static class DotNetProjectHelper
{
/// <summary>
/// Detects the target .NET runtime version (e.g. "8.0", "9.0") from a .csproj file.
/// Supports both TargetFramework and TargetFrameworks.
/// </summary>
/// <param name="projectFilePath">The full path to the .csproj file</param>
/// <param name="logger">Logger for diagnostic messages</param>
/// <returns>
/// The detected .NET version (e.g., "8.0", "9.0"), or null if:
/// - The file doesn't exist
/// - No TargetFramework element is found
/// - The TFM format is not recognized (only supports "netX.Y" format)
/// When multiple TFMs are specified, returns the first one.
/// It does NOT support legacy or library-only TFMs and Unsupported TFMs return null and fall back to default runtime selection.
/// </returns>
public static string? DetectTargetRuntimeVersion(string projectFilePath, ILogger logger)
{
if (!File.Exists(projectFilePath))
{
logger.LogWarning("Project file not found: {Path}", projectFilePath);
return null;
}

var content = File.ReadAllText(projectFilePath);

// Match <TargetFramework> or <TargetFrameworks>
var tfmMatch = Regex.Match(
content,
@"<TargetFrameworks?>\s*([^<]+)\s*</TargetFrameworks?>",
RegexOptions.IgnoreCase);

if (!tfmMatch.Success)
{
logger.LogWarning("No TargetFramework(s) found in project file: {Path}", projectFilePath);
return null;
}

// If multiple TFMs are specified, pick the first one
// (future improvement: pick highest)
var tfms = tfmMatch.Groups[1].Value
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

var tfm = tfms.FirstOrDefault();
if (string.IsNullOrWhiteSpace(tfm))
{
return null;
}

// Match net8.0, net9.0, net9.0-windows, etc.
var verMatch = Regex.Match(
tfm,
@"net(\d+)\.(\d+)",
RegexOptions.IgnoreCase);

if (!verMatch.Success)
{
logger.LogWarning("Unrecognized TargetFramework format: {Tfm}", tfm);
return null;
}

var version = $"{verMatch.Groups[1].Value}.{verMatch.Groups[2].Value}";
logger.LogInformation(
"Detected TargetFramework: {Tfm} → .NET {Version}",
tfm,
version);

return version;
}
}
Loading