|
// 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 System.Xml.Linq;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
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 IDotNetCliRunner _dotNetCliRunner;
private readonly IDotNetSdkInstaller _sdkInstaller;
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 directory.</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 (NuGet-only path).</param>
/// <param name="dotNetCliRunner">The .NET CLI runner for building project references.</param>
/// <param name="sdkInstaller">The SDK installer for checking .NET SDK availability.</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,
IDotNetCliRunner dotNetCliRunner,
IDotNetSdkInstaller sdkInstaller,
IPackagingService packagingService,
IConfigurationService configurationService,
ILogger logger)
{
_appPath = Path.GetFullPath(appPath);
_socketPath = socketPath;
_layout = layout;
_nugetService = nugetService;
_dotNetCliRunner = dotNetCliRunner;
_sdkInstaller = sdkInstaller;
_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 aspire-managed executable (used as the server).
/// </summary>
public string GetServerPath()
{
var managedPath = _layout.GetManagedPath();
if (managedPath is null || !File.Exists(managedPath))
{
throw new InvalidOperationException("aspire-managed not found in layout.");
}
return managedPath;
}
/// <inheritdoc />
public async Task<AppHostServerPrepareResult> PrepareAsync(
string sdkVersion,
IEnumerable<IntegrationReference> integrations,
CancellationToken cancellationToken = default)
{
var integrationList = integrations.ToList();
var packageRefs = integrationList.Where(r => r.IsPackageReference).ToList();
var projectRefs = integrationList.Where(r => r.IsProjectReference).ToList();
try
{
// Resolve the configured channel (local settings.json → global config fallback)
var channelName = await ResolveChannelNameAsync(cancellationToken);
if (projectRefs.Count > 0)
{
// Project references require .NET SDK — verify it's available
var (sdkAvailable, _, minimumRequired) = await _sdkInstaller.CheckAsync(cancellationToken);
if (!sdkAvailable)
{
throw new InvalidOperationException(
$"Project references in settings.json require .NET SDK {minimumRequired} or later. " +
"Install the .NET SDK from https://dotnet.microsoft.com/download or use NuGet package versions instead.");
}
// Build a synthetic project with all package and project references
_integrationLibsPath = await BuildIntegrationProjectAsync(
packageRefs, projectRefs, channelName, cancellationToken);
}
else if (packageRefs.Count > 0)
{
// NuGet-only — use the bundled NuGet service (no SDK required)
_integrationLibsPath = await RestoreNuGetPackagesAsync(
packageRefs, channelName, cancellationToken);
}
// Generate appsettings.json after build/restore so we can use actual assembly names
// from the build output (project references may have custom <AssemblyName>)
var projectRefAssemblyNames = _integrationLibsPath is not null
? await ReadProjectRefAssemblyNamesAsync(_integrationLibsPath, cancellationToken)
: [];
await GenerateAppSettingsAsync(packageRefs, projectRefAssemblyNames, 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>
/// Restores NuGet packages using the bundled NuGet service (no .NET SDK required).
/// </summary>
private async Task<string> RestoreNuGetPackagesAsync(
List<IntegrationReference> packageRefs,
string? channelName,
CancellationToken cancellationToken)
{
_logger.LogDebug("Restoring {Count} integration packages via bundled NuGet", packageRefs.Count);
var packages = packageRefs.Select(r => (r.Name, r.Version!)).ToList();
var sources = await GetNuGetSourcesAsync(channelName, cancellationToken);
var appHostDirectory = Path.GetDirectoryName(_appPath);
return await _nugetService.RestorePackagesAsync(
packages,
DotNetBasedAppHostServerProject.TargetFramework,
sources: sources,
workingDirectory: appHostDirectory,
ct: cancellationToken);
}
/// <summary>
/// Creates a synthetic .csproj with all package and project references,
/// then builds it to get the full transitive DLL closure via CopyLocalLockFileAssemblies.
/// Requires .NET SDK.
/// </summary>
private async Task<string> BuildIntegrationProjectAsync(
List<IntegrationReference> packageRefs,
List<IntegrationReference> projectRefs,
string? channelName,
CancellationToken cancellationToken)
{
var restoreDir = Path.Combine(_workingDirectory, "integration-restore");
Directory.CreateDirectory(restoreDir);
var outputDir = Path.Combine(restoreDir, "libs");
// Clean stale DLLs from previous builds to prevent leftover assemblies
// from removed integrations being picked up by the assembly resolver
if (Directory.Exists(outputDir))
{
Directory.Delete(outputDir, recursive: true);
}
Directory.CreateDirectory(outputDir);
// Resolve channel sources to add via RestoreAdditionalProjectSources
IEnumerable<string>? channelSources = null;
try
{
channelSources = await GetNuGetSourcesAsync(channelName, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to configure NuGet sources for integration project build");
}
var projectContent = GenerateIntegrationProjectFile(packageRefs, projectRefs, outputDir, channelSources);
var projectFilePath = Path.Combine(restoreDir, "IntegrationRestore.csproj");
await File.WriteAllTextAsync(projectFilePath, projectContent, cancellationToken);
// Write a Directory.Packages.props to opt out of Central Package Management
var directoryPackagesProps = """
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>
""";
await File.WriteAllTextAsync(
Path.Combine(restoreDir, "Directory.Packages.props"), directoryPackagesProps, cancellationToken);
// Also write an empty Directory.Build.props/targets to prevent parent imports
await File.WriteAllTextAsync(
Path.Combine(restoreDir, "Directory.Build.props"), "<Project />", cancellationToken);
await File.WriteAllTextAsync(
Path.Combine(restoreDir, "Directory.Build.targets"), "<Project />", cancellationToken);
_logger.LogDebug("Building integration project with {PackageCount} packages and {ProjectCount} project references",
packageRefs.Count, projectRefs.Count);
var buildOutput = new OutputCollector();
var exitCode = await _dotNetCliRunner.BuildAsync(
new FileInfo(projectFilePath),
noRestore: false,
new DotNetCliRunnerInvocationOptions
{
StandardOutputCallback = buildOutput.AppendOutput,
StandardErrorCallback = buildOutput.AppendError
},
cancellationToken);
if (exitCode != 0)
{
var outputLines = string.Join(Environment.NewLine, buildOutput.GetLines().Select(l => l.Line));
_logger.LogError("Integration project build failed. Output:\n{BuildOutput}", outputLines);
throw new InvalidOperationException($"Failed to build integration project. Exit code: {exitCode}");
}
return outputDir;
}
/// <summary>
/// Generates a synthetic .csproj file that references all integration packages and projects.
/// Building this project with CopyLocalLockFileAssemblies produces the full transitive DLL closure.
/// </summary>
internal static string GenerateIntegrationProjectFile(
List<IntegrationReference> packageRefs,
List<IntegrationReference> projectRefs,
string outputDir,
IEnumerable<string>? additionalSources = null)
{
var propertyGroup = new XElement("PropertyGroup",
new XElement("TargetFramework", DotNetBasedAppHostServerProject.TargetFramework),
new XElement("EnableDefaultItems", "false"),
new XElement("CopyLocalLockFileAssemblies", "true"),
new XElement("ProduceReferenceAssembly", "false"),
new XElement("EnableNETAnalyzers", "false"),
new XElement("GenerateDocumentationFile", "false"),
new XElement("OutDir", outputDir));
// Add channel sources without replacing the user's nuget.config
if (additionalSources is not null)
{
var sourceList = string.Join(";", additionalSources);
if (sourceList.Length > 0)
{
propertyGroup.Add(new XElement("RestoreAdditionalProjectSources", sourceList));
}
}
var doc = new XDocument(
new XElement("Project",
new XAttribute("Sdk", "Microsoft.NET.Sdk"),
propertyGroup));
if (packageRefs.Count > 0)
{
doc.Root!.Add(new XElement("ItemGroup",
packageRefs.Select(p =>
{
if (p.Version is null)
{
throw new InvalidOperationException($"Package reference '{p.Name}' is missing a version.");
}
return new XElement("PackageReference",
new XAttribute("Include", p.Name),
new XAttribute("Version", p.Version));
})));
}
if (projectRefs.Count > 0)
{
doc.Root!.Add(new XElement("ItemGroup",
projectRefs.Select(p => new XElement("ProjectReference",
new XAttribute("Include", p.ProjectPath!)))));
// Add a target that writes the resolved project reference assembly names to a file.
// This lets us discover the actual assembly names after build (which may differ from
// the settings.json key or csproj filename if <AssemblyName> is overridden).
doc.Root!.Add(
new XElement("Target",
new XAttribute("Name", "_WriteProjectRefAssemblyNames"),
new XAttribute("AfterTargets", "Build"),
new XElement("WriteLinesToFile",
new XAttribute("File", Path.Combine(outputDir, "_project-ref-assemblies.txt")),
new XAttribute("Lines", "@(_ResolvedProjectReferencePaths->'%(Filename)')"),
new XAttribute("Overwrite", "true"))));
}
return doc.ToString();
}
/// <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 for bundled restore.
/// </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))
{
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
{
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);
}
}
}
}
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();
// aspire-managed is self-contained - run directly
var startInfo = new ProcessStartInfo(serverPath)
{
WorkingDirectory = _workingDirectory,
WindowStyle = ProcessWindowStyle.Minimized,
UseShellExecute = false,
CreateNoWindow = true
};
// Insert "server" subcommand, then remaining args
startInfo.ArgumentList.Add("server");
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);
// 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;
}
// Set the dashboard path so the AppHost can locate and launch the dashboard binary
var managedPath = _layout.GetManagedPath();
if (managedPath is not null)
{
startInfo.Environment[BundleDiscovery.DashboardPathEnvVar] = managedPath;
}
// 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;
/// <summary>
/// Reads the project reference assembly names written by the MSBuild target during build.
/// </summary>
private async Task<List<string>> ReadProjectRefAssemblyNamesAsync(string libsPath, CancellationToken cancellationToken)
{
var filePath = Path.Combine(libsPath, "_project-ref-assemblies.txt");
if (!File.Exists(filePath))
{
_logger.LogWarning("Project reference assembly names file not found at {Path}", filePath);
return [];
}
var lines = await File.ReadAllLinesAsync(filePath, cancellationToken);
return lines.Where(l => !string.IsNullOrWhiteSpace(l)).Select(l => l.Trim()).ToList();
}
private async Task GenerateAppSettingsAsync(
List<IntegrationReference> packageRefs,
List<string> projectRefAssemblyNames,
CancellationToken cancellationToken)
{
var atsAssemblies = new List<string> { "Aspire.Hosting" };
foreach (var pkg in packageRefs)
{
if (pkg.Name.Equals("Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase) ||
pkg.Name.StartsWith("Aspire.AppHost.Sdk", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!atsAssemblies.Contains(pkg.Name, StringComparer.OrdinalIgnoreCase))
{
atsAssemblies.Add(pkg.Name);
}
}
foreach (var name in projectRefAssemblyNames)
{
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);
}
}
|