|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Cli.Backchannel;
using Aspire.Cli.Certificates;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Shared.UserSecrets;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.Projects;
/// <summary>
/// Handler for .NET AppHost projects (.csproj and single-file .cs).
/// </summary>
internal sealed class DotNetAppHostProject : IAppHostProject
{
private readonly IDotNetCliRunner _runner;
private readonly IInteractionService _interactionService;
private readonly ICertificateService _certificateService;
private readonly AspireCliTelemetry _telemetry;
private readonly IFeatures _features;
private readonly ILogger<DotNetAppHostProject> _logger;
private readonly TimeProvider _timeProvider;
private readonly IProjectUpdater _projectUpdater;
private readonly RunningInstanceManager _runningInstanceManager;
private static readonly string[] s_detectionPatterns = ["*.csproj", "*.fsproj", "*.vbproj", "apphost.cs"];
private static readonly string[] s_projectExtensions = [".csproj", ".fsproj", ".vbproj"];
public DotNetAppHostProject(
IDotNetCliRunner runner,
IInteractionService interactionService,
ICertificateService certificateService,
AspireCliTelemetry telemetry,
IFeatures features,
IProjectUpdater projectUpdater,
ILogger<DotNetAppHostProject> logger,
TimeProvider? timeProvider = null)
{
_runner = runner;
_interactionService = interactionService;
_certificateService = certificateService;
_telemetry = telemetry;
_features = features;
_projectUpdater = projectUpdater;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
_runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider);
}
// ═══════════════════════════════════════════════════════════════
// IDENTITY
// ═══════════════════════════════════════════════════════════════
/// <inheritdoc />
public string LanguageId => KnownLanguageId.CSharp;
/// <inheritdoc />
public string DisplayName => "C# (.NET)";
// ═══════════════════════════════════════════════════════════════
// DETECTION
// ═══════════════════════════════════════════════════════════════
/// <inheritdoc />
public Task<string[]> GetDetectionPatternsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(s_detectionPatterns);
/// <inheritdoc />
public bool CanHandle(FileInfo appHostFile)
{
var extension = appHostFile.Extension.ToLowerInvariant();
// Handle project files (.csproj, .fsproj, .vbproj)
if (s_projectExtensions.Contains(extension))
{
// We can handle any project file - ValidateAsync will do deeper validation
return true;
}
// Handle single-file apphosts (apphost.cs)
if (extension == ".cs" && appHostFile.Name.Equals("apphost.cs", StringComparison.OrdinalIgnoreCase))
{
// Check for #:sdk Aspire.AppHost.Sdk directive
return IsValidSingleFileAppHost(appHostFile);
}
return false;
}
private static bool IsValidSingleFileAppHost(FileInfo candidateFile)
{
// Check no sibling .csproj files exist
var siblingCsprojFiles = candidateFile.Directory!.EnumerateFiles("*.csproj", SearchOption.TopDirectoryOnly);
if (siblingCsprojFiles.Any())
{
return false;
}
// Check for #:sdk Aspire.AppHost.Sdk directive
try
{
using var reader = candidateFile.OpenText();
string? line;
while ((line = reader.ReadLine()) is not null)
{
var trimmedLine = line.TrimStart();
if (trimmedLine.StartsWith("#:sdk Aspire.AppHost.Sdk", StringComparison.Ordinal))
{
return true;
}
}
}
catch
{
return false;
}
return false;
}
// ═══════════════════════════════════════════════════════════════
// CREATION
// ═══════════════════════════════════════════════════════════════
/// <inheritdoc />
public string? AppHostFileName => "apphost.cs";
// ═══════════════════════════════════════════════════════════════
// EXECUTION
// ═══════════════════════════════════════════════════════════════
/// <inheritdoc />
public async Task<AppHostValidationResult> ValidateAppHostAsync(FileInfo appHostFile, CancellationToken cancellationToken)
{
var isSingleFile = appHostFile.Extension.Equals(".cs", StringComparison.OrdinalIgnoreCase);
if (isSingleFile)
{
// For single-file apphosts, validate that:
// 1. No sibling .csproj files exist (otherwise it's part of a project)
// 2. The file contains the #:sdk Aspire.AppHost.Sdk directive
return new AppHostValidationResult(IsValid: IsValidSingleFileAppHost(appHostFile));
}
// For project files, check if it's a valid Aspire AppHost using GetAppHostInformationAsync
var information = await _runner.GetAppHostInformationAsync(appHostFile, new DotNetCliRunnerInvocationOptions(), cancellationToken);
if (information.ExitCode == 0 && information.IsAspireHost)
{
return new AppHostValidationResult(IsValid: true);
}
// Check if it's possibly an unbuildable AppHost (has the right name pattern but couldn't be validated)
var isPossiblyUnbuildable = IsPossiblyUnbuildableAppHost(appHostFile);
return new AppHostValidationResult(
IsValid: false,
IsPossiblyUnbuildable: isPossiblyUnbuildable);
}
private static bool IsPossiblyUnbuildableAppHost(FileInfo projectFile)
{
var fileNameSuggestsAppHost = projectFile.Name.EndsWith("AppHost.csproj", StringComparison.OrdinalIgnoreCase);
var folderContainsAppHostCSharpFile = projectFile.Directory!
.EnumerateFiles("*", SearchOption.TopDirectoryOnly)
.Any(f => f.Name.Equals("AppHost.cs", StringComparison.OrdinalIgnoreCase));
return fileNameSuggestsAppHost || folderContainsAppHostCSharpFile;
}
/// <inheritdoc />
public async Task<int> RunAsync(AppHostProjectContext context, CancellationToken cancellationToken)
{
var effectiveAppHostFile = context.AppHostFile;
var isExtensionHost = ExtensionHelper.IsExtensionHost(_interactionService, out _, out var extensionBackchannel);
var buildOutputCollector = new OutputCollector();
(bool IsCompatibleAppHost, bool SupportsBackchannel, string? AspireHostingVersion)? appHostCompatibilityCheck = null;
using var activity = _telemetry.StartDiagnosticActivity("run");
var isSingleFileAppHost = effectiveAppHostFile.Extension != ".csproj";
var env = new Dictionary<string, string>(context.EnvironmentVariables);
// Handle isolated mode - randomize ports and isolate user secrets
string? isolatedUserSecretsId = null;
if (context.Isolated)
{
isolatedUserSecretsId = await ConfigureIsolatedModeAsync(effectiveAppHostFile, env, cancellationToken);
_logger.LogInformation("Aspire run isolated. Isolated UserSecretsId: {IsolatedUserSecretsId}", isolatedUserSecretsId);
}
if (context.WaitForDebugger)
{
env[KnownConfigNames.WaitForDebugger] = "true";
}
try
{
var certResult = await _certificateService.EnsureCertificatesTrustedAsync(_runner, cancellationToken);
// Apply any environment variables returned by the certificate service (e.g., SSL_CERT_DIR on Linux)
foreach (var kvp in certResult.EnvironmentVariables)
{
env[kvp.Key] = kvp.Value;
}
}
catch
{
// Signal that build/preparation failed so RunCommand doesn't hang waiting
context.BuildCompletionSource?.TrySetResult(false);
throw;
}
var watch = !isSingleFileAppHost && (_features.IsFeatureEnabled(KnownFeatures.DefaultWatchEnabled, defaultValue: false) || (isExtensionHost && !context.StartDebugSession));
try
{
if (!watch)
{
// Build in CLI if either not running under extension host, or the extension reports 'build-dotnet-using-cli' capability.
var extensionHasBuildCapability = extensionBackchannel is not null && await extensionBackchannel.HasCapabilityAsync(KnownCapabilities.BuildDotnetUsingCli, cancellationToken);
var shouldBuildInCli = !isExtensionHost || extensionHasBuildCapability;
if (shouldBuildInCli)
{
var buildOptions = new DotNetCliRunnerInvocationOptions
{
StandardOutputCallback = buildOutputCollector.AppendOutput,
StandardErrorCallback = buildOutputCollector.AppendError,
};
var buildExitCode = await AppHostHelper.BuildAppHostAsync(_runner, _interactionService, effectiveAppHostFile, buildOptions, context.WorkingDirectory, cancellationToken);
if (buildExitCode != 0)
{
// Set OutputCollector so RunCommand can display errors
context.OutputCollector = buildOutputCollector;
context.BuildCompletionSource?.TrySetResult(false);
return ExitCodeConstants.FailedToBuildArtifacts;
}
}
}
if (isSingleFileAppHost)
{
appHostCompatibilityCheck = (true, true, VersionHelper.GetDefaultTemplateVersion());
}
else
{
appHostCompatibilityCheck = await AppHostHelper.CheckAppHostCompatibilityAsync(_runner, _interactionService, effectiveAppHostFile, _telemetry, context.WorkingDirectory, cancellationToken);
}
}
catch
{
// Signal that build/preparation failed so RunCommand doesn't hang waiting
context.BuildCompletionSource?.TrySetResult(false);
throw;
}
if (!appHostCompatibilityCheck?.IsCompatibleAppHost ?? throw new InvalidOperationException(RunCommandStrings.IsCompatibleAppHostIsNull))
{
context.BuildCompletionSource?.TrySetResult(false);
return ExitCodeConstants.FailedToDotnetRunAppHost;
}
// Create collector and store in context for exception handling
// This must be set BEFORE signaling build completion to avoid a race condition
var runOutputCollector = new OutputCollector();
context.OutputCollector = runOutputCollector;
// Signal that build/preparation is complete
context.BuildCompletionSource?.TrySetResult(true);
var runOptions = new DotNetCliRunnerInvocationOptions
{
StandardOutputCallback = runOutputCollector.AppendOutput,
StandardErrorCallback = runOutputCollector.AppendError,
StartDebugSession = context.StartDebugSession,
Debug = context.Debug
};
// The backchannel completion source is the contract with RunCommand
// We signal this when the backchannel is ready, RunCommand uses it for UX
var backchannelCompletionSource = context.BackchannelCompletionSource ?? new TaskCompletionSource<IAppHostCliBackchannel>();
if (isSingleFileAppHost)
{
ConfigureSingleFileEnvironment(effectiveAppHostFile, env);
}
// Start the apphost - the runner will signal the backchannel when ready
try
{
return await _runner.RunAsync(
effectiveAppHostFile,
watch,
!watch,
context.UnmatchedTokens,
env,
backchannelCompletionSource,
runOptions,
cancellationToken);
}
finally
{
// Clean up isolated user secrets when the run completes
if (!string.IsNullOrEmpty(isolatedUserSecretsId))
{
IsolatedUserSecretsHelper.CleanupIsolatedUserSecrets(isolatedUserSecretsId);
}
}
}
private static void ConfigureSingleFileEnvironment(FileInfo appHostFile, Dictionary<string, string> env)
{
var runJsonFilePath = appHostFile.FullName[..^2] + "run.json";
if (!File.Exists(runJsonFilePath))
{
env["ASPNETCORE_ENVIRONMENT"] = "Development";
env["DOTNET_ENVIRONMENT"] = "Development";
env["ASPNETCORE_URLS"] = "https://localhost:17193;http://localhost:15069";
env["ASPIRE_DASHBOARD_MCP_ENDPOINT_URL"] = "https://localhost:21294";
env["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:21293";
env["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = "https://localhost:22086";
}
}
/// <inheritdoc />
public async Task<int> PublishAsync(PublishContext context, CancellationToken cancellationToken)
{
var effectiveAppHostFile = context.AppHostFile;
var isSingleFileAppHost = effectiveAppHostFile.Extension != ".csproj";
var env = new Dictionary<string, string>(context.EnvironmentVariables);
// Check compatibility for project-based apphosts
if (!isSingleFileAppHost)
{
var compatibilityCheck = await AppHostHelper.CheckAppHostCompatibilityAsync(
_runner,
_interactionService,
effectiveAppHostFile,
_telemetry,
context.WorkingDirectory,
cancellationToken);
if (!compatibilityCheck.IsCompatibleAppHost)
{
var exception = new AppHostIncompatibleException(
$"The app host is not compatible. Aspire.Hosting version: {compatibilityCheck.AspireHostingVersion}",
"Aspire.Hosting");
// Signal the backchannel completion source so the caller doesn't wait forever
context.BackchannelCompletionSource?.TrySetException(exception);
throw exception;
}
// Build the apphost
var buildOutputCollector = new OutputCollector();
var buildOptions = new DotNetCliRunnerInvocationOptions
{
StandardOutputCallback = buildOutputCollector.AppendOutput,
StandardErrorCallback = buildOutputCollector.AppendError,
};
var buildExitCode = await AppHostHelper.BuildAppHostAsync(
_runner,
_interactionService,
effectiveAppHostFile,
buildOptions,
context.WorkingDirectory,
cancellationToken);
if (buildExitCode != 0)
{
// Set OutputCollector so PipelineCommandBase can display errors
context.OutputCollector = buildOutputCollector;
// Signal the backchannel completion source so the caller doesn't wait forever
context.BackchannelCompletionSource?.TrySetException(
new InvalidOperationException("The app host build failed."));
return ExitCodeConstants.FailedToBuildArtifacts;
}
}
// Create collector and store in context for exception handling
var runOutputCollector = new OutputCollector();
context.OutputCollector = runOutputCollector;
var runOptions = new DotNetCliRunnerInvocationOptions
{
StandardOutputCallback = runOutputCollector.AppendOutput,
StandardErrorCallback = runOutputCollector.AppendError,
NoLaunchProfile = true,
NoExtensionLaunch = true
};
if (isSingleFileAppHost)
{
ConfigureSingleFileEnvironment(effectiveAppHostFile, env);
}
return await _runner.RunAsync(
effectiveAppHostFile,
watch: false,
noBuild: true,
context.Arguments,
env,
context.BackchannelCompletionSource,
runOptions,
cancellationToken);
}
/// <inheritdoc />
public async Task<bool> AddPackageAsync(AddPackageContext context, CancellationToken cancellationToken)
{
var outputCollector = new OutputCollector();
context.OutputCollector = outputCollector;
var options = new DotNetCliRunnerInvocationOptions
{
StandardOutputCallback = outputCollector.AppendOutput,
StandardErrorCallback = outputCollector.AppendError,
};
var result = await _runner.AddPackageAsync(
context.AppHostFile,
context.PackageId,
context.PackageVersion,
context.Source,
options,
cancellationToken);
return result == 0;
}
/// <inheritdoc />
public async Task<UpdatePackagesResult> UpdatePackagesAsync(UpdatePackagesContext context, CancellationToken cancellationToken)
{
var result = await _projectUpdater.UpdateProjectAsync(context.AppHostFile, context.Channel, cancellationToken);
return new UpdatePackagesResult { UpdatesApplied = result.UpdatedApplied };
}
/// <inheritdoc />
public async Task<RunningInstanceResult> CheckAndHandleRunningInstanceAsync(FileInfo appHostFile, DirectoryInfo homeDirectory, CancellationToken cancellationToken)
{
var matchingSockets = AppHostHelper.FindMatchingSockets(appHostFile.FullName, homeDirectory.FullName);
// Check if any socket files exist
if (matchingSockets.Length == 0)
{
return RunningInstanceResult.NoRunningInstance;
}
// Stop all running instances
var stopTasks = matchingSockets.Select(socketPath =>
_runningInstanceManager.StopRunningInstanceAsync(socketPath, cancellationToken));
var results = await Task.WhenAll(stopTasks);
return results.All(r => r) ? RunningInstanceResult.InstanceStopped : RunningInstanceResult.StopFailed;
}
/// <summary>
/// Gets the UserSecretsId from a project file.
/// </summary>
private async Task<string?> GetUserSecretsIdAsync(FileInfo projectFile, CancellationToken cancellationToken)
{
try
{
var (exitCode, jsonDocument) = await _runner.GetProjectItemsAndPropertiesAsync(
projectFile,
items: [],
properties: ["UserSecretsId"],
new DotNetCliRunnerInvocationOptions(),
cancellationToken);
if (exitCode != 0 || jsonDocument is null)
{
return null;
}
var rootElement = jsonDocument.RootElement;
if (rootElement.TryGetProperty("Properties", out var properties) &&
properties.TryGetProperty("UserSecretsId", out var userSecretsIdElement))
{
return userSecretsIdElement.GetString();
}
return null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to get UserSecretsId from project file");
return null;
}
}
/// <summary>
/// Configures isolated mode by enabling port randomization and isolating user secrets.
/// </summary>
/// <param name="appHostFile">The app host project file.</param>
/// <param name="env">The environment variables dictionary to modify.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The isolated user secrets ID if created, or null if no isolation was needed.</returns>
private async Task<string?> ConfigureIsolatedModeAsync(
FileInfo appHostFile,
Dictionary<string, string> env,
CancellationToken cancellationToken)
{
// Enable port randomization for isolated mode
env["DcpPublisher__RandomizePorts"] = "true";
// Get the UserSecretsId from the project and create isolated copy
var userSecretsId = await GetUserSecretsIdAsync(appHostFile, cancellationToken);
if (!string.IsNullOrEmpty(userSecretsId))
{
_interactionService.DisplayMessage("key", RunCommandStrings.CopyingUserSecrets);
var isolatedUserSecretsId = IsolatedUserSecretsHelper.CreateIsolatedUserSecrets(userSecretsId);
if (!string.IsNullOrEmpty(isolatedUserSecretsId))
{
// Override the user secrets ID for this run
env["DOTNET_USER_SECRETS_ID"] = isolatedUserSecretsId;
return isolatedUserSecretsId;
}
}
return null;
}
}
|