|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using Aspire.Cli.Configuration;
using Aspire.Cli.Layout;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.Projects;
/// <summary>
/// Manages a pre-built AppHost server from the Aspire bundle layout.
/// This is used when running in bundle mode (without .NET SDK) to avoid
/// dynamic project generation and building.
/// </summary>
internal sealed class PrebuiltAppHostServer : IAppHostServerProject
{
private readonly string _appPath;
private readonly string _socketPath;
private readonly LayoutConfiguration _layout;
private readonly BundleNuGetService _nugetService;
private readonly IPackagingService _packagingService;
private readonly IConfigurationService _configurationService;
private readonly ILogger _logger;
private readonly string _workingDirectory;
// Path to restored integration libraries (set during PrepareAsync)
private string? _integrationLibsPath;
/// <summary>
/// Initializes a new instance of the PrebuiltAppHostServer class.
/// </summary>
/// <param name="appPath">The path to the user's polyglot app host.</param>
/// <param name="socketPath">The socket path for JSON-RPC communication.</param>
/// <param name="layout">The bundle layout configuration.</param>
/// <param name="nugetService">The NuGet service for restoring integration packages.</param>
/// <param name="packagingService">The packaging service for channel resolution.</param>
/// <param name="configurationService">The configuration service for reading channel settings.</param>
/// <param name="logger">The logger for diagnostic output.</param>
public PrebuiltAppHostServer(
string appPath,
string socketPath,
LayoutConfiguration layout,
BundleNuGetService nugetService,
IPackagingService packagingService,
IConfigurationService configurationService,
ILogger logger)
{
_appPath = Path.GetFullPath(appPath);
_socketPath = socketPath;
_layout = layout;
_nugetService = nugetService;
_packagingService = packagingService;
_configurationService = configurationService;
_logger = logger;
// Create a working directory for this app host session
var pathHash = SHA256.HashData(Encoding.UTF8.GetBytes(_appPath));
var pathDir = Convert.ToHexString(pathHash)[..12].ToLowerInvariant();
_workingDirectory = Path.Combine(Path.GetTempPath(), ".aspire", "bundle-hosts", pathDir);
Directory.CreateDirectory(_workingDirectory);
}
/// <inheritdoc />
public string AppPath => _appPath;
/// <summary>
/// Gets the path to the pre-built AppHost server (exe or DLL).
/// </summary>
public string GetServerPath()
{
var serverPath = _layout.GetAppHostServerPath();
if (serverPath is null || !File.Exists(serverPath))
{
throw new InvalidOperationException("Pre-built AppHost server not found in layout.");
}
return serverPath;
}
/// <inheritdoc />
public async Task<AppHostServerPrepareResult> PrepareAsync(
string sdkVersion,
IEnumerable<(string Name, string Version)> packages,
CancellationToken cancellationToken = default)
{
var packageList = packages.ToList();
try
{
// Generate appsettings.json with ATS assemblies for the server to scan
await GenerateAppSettingsAsync(packageList, cancellationToken);
// Resolve the configured channel (local settings.json → global config fallback)
var channelName = await ResolveChannelNameAsync(cancellationToken);
// Restore integration packages
if (packageList.Count > 0)
{
_logger.LogDebug("Restoring {Count} integration packages", packageList.Count);
// Get NuGet sources filtered to the resolved channel
var sources = await GetNuGetSourcesAsync(channelName, cancellationToken);
// Pass apphost directory for nuget.config discovery
var appHostDirectory = Path.GetDirectoryName(_appPath);
_integrationLibsPath = await _nugetService.RestorePackagesAsync(
packageList,
"net10.0",
sources: sources,
workingDirectory: appHostDirectory,
ct: cancellationToken);
}
return new AppHostServerPrepareResult(
Success: true,
Output: null,
ChannelName: channelName,
NeedsCodeGeneration: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to prepare prebuilt AppHost server");
var output = new OutputCollector();
output.AppendError($"Failed to prepare: {ex.Message}");
return new AppHostServerPrepareResult(
Success: false,
Output: output,
ChannelName: null,
NeedsCodeGeneration: false);
}
}
/// <summary>
/// Resolves the configured channel name from local settings.json or global config.
/// </summary>
private async Task<string?> ResolveChannelNameAsync(CancellationToken cancellationToken)
{
// Check local settings.json first
var localConfig = AspireJsonConfiguration.Load(Path.GetDirectoryName(_appPath)!);
var channelName = localConfig?.Channel;
// Fall back to global config
if (string.IsNullOrEmpty(channelName))
{
channelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken);
}
if (!string.IsNullOrEmpty(channelName))
{
_logger.LogDebug("Resolved channel: {Channel}", channelName);
}
return channelName;
}
/// <summary>
/// Gets NuGet sources from the resolved channel, or all explicit channels if no channel is configured.
/// </summary>
private async Task<IEnumerable<string>?> GetNuGetSourcesAsync(string? channelName, CancellationToken cancellationToken)
{
var sources = new List<string>();
try
{
var channels = await _packagingService.GetChannelsAsync(cancellationToken);
IEnumerable<PackageChannel> explicitChannels;
if (!string.IsNullOrEmpty(channelName))
{
// Filter to the configured channel
var matchingChannel = channels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase));
explicitChannels = matchingChannel is not null ? [matchingChannel] : channels.Where(c => c.Type == PackageChannelType.Explicit);
}
else
{
// No channel configured, use all explicit channels
explicitChannels = channels.Where(c => c.Type == PackageChannelType.Explicit);
}
foreach (var channel in explicitChannels)
{
if (channel.Mappings is null)
{
continue;
}
foreach (var mapping in channel.Mappings)
{
if (!sources.Contains(mapping.Source, StringComparer.OrdinalIgnoreCase))
{
sources.Add(mapping.Source);
_logger.LogDebug("Using channel '{Channel}' NuGet source: {Source}", channel.Name, mapping.Source);
}
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get package channels, relying on nuget.config and nuget.org fallback");
}
return sources.Count > 0 ? sources : null;
}
/// <inheritdoc />
public (string SocketPath, Process Process, OutputCollector OutputCollector) Run(
int hostPid,
IReadOnlyDictionary<string, string>? environmentVariables = null,
string[]? additionalArgs = null,
bool debug = false)
{
var serverPath = GetServerPath();
// Get runtime path for DOTNET_ROOT
var runtimePath = _layout.GetDotNetExePath();
var runtimeDir = runtimePath is not null ? Path.GetDirectoryName(runtimePath) : null;
// Bundle always uses single-file executables - run directly
var startInfo = new ProcessStartInfo(serverPath)
{
WorkingDirectory = _workingDirectory,
WindowStyle = ProcessWindowStyle.Minimized,
UseShellExecute = false,
CreateNoWindow = true
};
// Set DOTNET_ROOT so the executable can find the runtime
if (runtimeDir is not null)
{
startInfo.Environment["DOTNET_ROOT"] = runtimeDir;
startInfo.Environment["DOTNET_MULTILEVEL_LOOKUP"] = "0";
}
// Add arguments to point to our appsettings.json
startInfo.ArgumentList.Add("--");
startInfo.ArgumentList.Add("--contentRoot");
startInfo.ArgumentList.Add(_workingDirectory);
// Add any additional arguments
if (additionalArgs is { Length: > 0 })
{
foreach (var arg in additionalArgs)
{
startInfo.ArgumentList.Add(arg);
}
}
// Configure environment
startInfo.Environment["REMOTE_APP_HOST_SOCKET_PATH"] = _socketPath;
startInfo.Environment["REMOTE_APP_HOST_PID"] = hostPid.ToString(System.Globalization.CultureInfo.InvariantCulture);
startInfo.Environment[KnownConfigNames.CliProcessId] = hostPid.ToString(System.Globalization.CultureInfo.InvariantCulture);
// Also set ASPIRE_RUNTIME_PATH so DashboardEventHandlers knows which dotnet to use
if (runtimeDir is not null)
{
startInfo.Environment[BundleDiscovery.RuntimePathEnvVar] = runtimeDir;
}
// Pass the integration libs path so the server can resolve assemblies via AssemblyLoader
if (_integrationLibsPath is not null)
{
_logger.LogDebug("Setting ASPIRE_INTEGRATION_LIBS_PATH to {Path}", _integrationLibsPath);
startInfo.Environment["ASPIRE_INTEGRATION_LIBS_PATH"] = _integrationLibsPath;
}
else
{
_logger.LogWarning("Integration libs path is null - assemblies may not resolve correctly");
}
// Set DCP and Dashboard paths from the layout
var dcpPath = _layout.GetDcpPath();
if (dcpPath is not null)
{
startInfo.Environment[BundleDiscovery.DcpPathEnvVar] = dcpPath;
}
var dashboardPath = _layout.GetDashboardPath();
if (dashboardPath is not null)
{
// Bundle uses single-file executables
var dashboardExe = Path.Combine(dashboardPath, BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName));
startInfo.Environment[BundleDiscovery.DashboardPathEnvVar] = dashboardExe;
}
// Apply environment variables from apphost.run.json
if (environmentVariables is not null)
{
foreach (var (key, value) in environmentVariables)
{
startInfo.Environment[key] = value;
}
}
if (debug)
{
startInfo.Environment["Logging__LogLevel__Default"] = "Debug";
}
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
var process = Process.Start(startInfo)!;
var outputCollector = new OutputCollector();
process.OutputDataReceived += (_, e) =>
{
if (e.Data is not null)
{
_logger.LogDebug("PrebuiltAppHostServer({ProcessId}) stdout: {Line}", process.Id, e.Data);
outputCollector.AppendOutput(e.Data);
}
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data is not null)
{
_logger.LogDebug("PrebuiltAppHostServer({ProcessId}) stderr: {Line}", process.Id, e.Data);
outputCollector.AppendError(e.Data);
}
};
process.BeginOutputReadLine();
process.BeginErrorReadLine();
return (_socketPath, process, outputCollector);
}
/// <inheritdoc />
public string GetInstanceIdentifier() => _appPath;
private async Task GenerateAppSettingsAsync(
List<(string Name, string Version)> packages,
CancellationToken cancellationToken)
{
// Build the list of ATS assemblies (for [AspireExport] scanning)
// Skip SDK-only packages that don't have runtime DLLs
var atsAssemblies = new List<string> { "Aspire.Hosting" };
foreach (var (name, _) in packages)
{
// Skip SDK packages that don't produce runtime assemblies
if (name.Equals("Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase) ||
name.StartsWith("Aspire.AppHost.Sdk", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!atsAssemblies.Contains(name, StringComparer.OrdinalIgnoreCase))
{
atsAssemblies.Add(name);
}
}
var assembliesJson = string.Join(",\n ", atsAssemblies.Select(a => $"\"{a}\""));
var appSettingsJson = $$"""
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
},
"AtsAssemblies": [
{{assembliesJson}}
]
}
""";
await File.WriteAllTextAsync(
Path.Combine(_workingDirectory, "appsettings.json"),
appSettingsJson,
cancellationToken);
}
}
|