|
// 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 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 string[] DetectionPatterns => 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";
/// <inheritdoc />
public Task ScaffoldAsync(DirectoryInfo directory, string? projectName, CancellationToken cancellationToken)
{
// C# projects use the template system, not direct scaffolding
throw new NotSupportedException("C# projects should be created using the template system via NewCommand.");
}
// ═══════════════════════════════════════════════════════════════
// 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, we just check that it exists
return new AppHostValidationResult(IsValid: appHostFile.Exists);
}
// 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 buildOutputCollector = new OutputCollector();
(bool IsCompatibleAppHost, bool SupportsBackchannel, string? AspireHostingVersion)? appHostCompatibilityCheck = null;
using var activity = _telemetry.ActivitySource.StartActivity("run");
var isSingleFileAppHost = effectiveAppHostFile.Extension != ".csproj";
var env = new Dictionary<string, string>(context.EnvironmentVariables);
if (context.WaitForDebugger)
{
env[KnownConfigNames.WaitForDebugger] = "true";
}
try
{
await _certificateService.EnsureCertificatesTrustedAsync(_runner, cancellationToken);
}
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)
{
if (!isSingleFileAppHost && !isExtensionHost)
{
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
return await _runner.RunAsync(
effectiveAppHostFile,
watch,
!watch,
context.UnmatchedTokens,
env,
backchannelCompletionSource,
runOptions,
cancellationToken);
}
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<bool> CheckAndHandleRunningInstanceAsync(FileInfo appHostFile, DirectoryInfo homeDirectory, CancellationToken cancellationToken)
{
var auxiliarySocketPath = AppHostHelper.ComputeAuxiliarySocketPath(appHostFile.FullName, homeDirectory.FullName);
// Check if the socket file exists
if (!File.Exists(auxiliarySocketPath))
{
return true; // No running instance, continue
}
// Stop the running instance (no prompt per mitchdenny's request)
return await _runningInstanceManager.StopRunningInstanceAsync(auxiliarySocketPath, cancellationToken);
}
}
|