// 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.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text.Json;
using Aspire.Cli.Bundles;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Layout;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Processes;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
using Semver;
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, IDisposable
{
// Closure file names are owned by IntegrationClosureBuilder so generated integration
// projects cannot drift from the post-build reader's MSBuild contract.
internal const string ClosureManifestFileName = "closure-manifest.txt";
internal const string IntegrationProjectFileName = "IntegrationRestore.csproj";
internal const string IntegrationHostingVersionPropertyName = "AspireIntegrationHostingVersion";
internal const string IntegrationPackageSourcesPropertyName = "AspireIntegrationPackageSources";
internal const string IntegrationPackageSourceAliasPropertyName = "AspireIntegrationPackageSourceAlias";
private readonly string _appDirectoryPath;
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 CliExecutionContext _executionContext;
private readonly IProcessExecutionFactory _processExecutionFactory;
private readonly IEnvironment _environment;
private readonly ILogger _logger;
private readonly BundleLayoutLease? _layoutLease;
private readonly string _workloadId;
private readonly string _workingDirectory;
private readonly string _projectReferencePrepareLockPath;
private readonly AppHostServerProjectLayoutStore _projectLayoutStore;
private string? _contentRootPath;
private string? _integrationLibsPath;
private string? _integrationProbeManifestPath;
private AppHostServerProjectLayout? _selectedProjectLayout;
private bool _supportsNativeDashboard = true;
private bool _supportsDirectTerminalHost = true;
/// <summary>
/// Initializes a new instance of the PrebuiltAppHostServer class.
/// </summary>
/// <param name="appPath">The path to the user's polyglot app host directory (must be a directory path).</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="executionContext">The CLI execution context providing identity channel information.</param>
/// <param name="processExecutionFactory">The factory used to spawn and manage the AppHost server child process.</param>
/// <param name="environment">The environment abstraction for OS detection.</param>
/// <param name="logger">The logger for diagnostic output.</param>
/// <param name="layoutLease">The active bundle layout lease, if this server is running from a versioned bundle.</param>
/// <param name="workloadId">The AppHost workload identifier used to namespace generated NuGet source keys.</param>
public PrebuiltAppHostServer(
string appPath,
string socketPath,
LayoutConfiguration layout,
BundleNuGetService nugetService,
IDotNetCliRunner dotNetCliRunner,
IDotNetSdkInstaller sdkInstaller,
IPackagingService packagingService,
CliExecutionContext executionContext,
IProcessExecutionFactory processExecutionFactory,
IEnvironment environment,
ILogger logger,
BundleLayoutLease? layoutLease = null,
string? workloadId = null)
{
_appDirectoryPath = Path.GetFullPath(appPath);
_socketPath = socketPath;
_layout = layout;
_nugetService = nugetService;
_dotNetCliRunner = dotNetCliRunner;
_sdkInstaller = sdkInstaller;
_packagingService = packagingService;
_executionContext = executionContext;
_processExecutionFactory = processExecutionFactory;
_environment = environment;
_logger = logger;
_layoutLease = layoutLease;
_workloadId = workloadId ?? AppHostWorkloadId.Create(_appDirectoryPath);
_workingDirectory = IntegrationClosureBuilder.GetAppHostIntegrationCacheDirectory(new DirectoryInfo(_appDirectoryPath)).FullName;
Directory.CreateDirectory(_workingDirectory);
_projectReferencePrepareLockPath = Path.Combine(_workingDirectory, "project-layouts", "prepare.lock");
_projectLayoutStore = new AppHostServerProjectLayoutStore(_workingDirectory, _logger);
}
/// <inheritdoc />
public string AppDirectoryPath => _appDirectoryPath;
internal string? SelectedProjectLayoutFingerprint => _selectedProjectLayout?.Fingerprint;
internal string? SelectedProjectLayoutPath => _selectedProjectLayout?.LayoutPath;
internal string? IntegrationProbeManifestPath => _integrationProbeManifestPath;
/// <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;
}
private bool TryGetHostingVersion(string sdkVersion, [NotNullWhen(true)] out SemVersion? hostingVersion)
{
try
{
var hostingAssembly = IntegrationPackageProbeManifest.Load(_integrationProbeManifestPath).ManagedAssemblies
.FirstOrDefault(static assembly => assembly.Culture is null && string.Equals(assembly.Name, "Aspire.Hosting", StringComparison.OrdinalIgnoreCase));
if (SemVersion.TryParse(hostingAssembly?.PackageVersion, out hostingVersion))
{
return true;
}
var assemblyPath = hostingAssembly?.Path ?? (_integrationLibsPath is not null ? Path.Combine(_integrationLibsPath, "Aspire.Hosting.dll") : null);
if (assemblyPath is not null && File.Exists(assemblyPath))
{
var assemblyVersion = AssemblyName.GetAssemblyName(assemblyPath).Version;
hostingVersion = assemblyVersion is not null
? new SemVersion(assemblyVersion.Major, assemblyVersion.Minor, assemblyVersion.Build)
: null;
return hostingVersion is not null;
}
return SemVersion.TryParse(sdkVersion, out hostingVersion);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Unable to determine the Hosting version; using the Dashboard compatibility dispatcher.");
hostingVersion = null;
return false;
}
}
/// <inheritdoc />
public async Task<AppHostServerPrepareResult> PrepareAsync(
string sdkVersion,
IEnumerable<IntegrationReference> integrations,
string? requestedChannel = null,
string? packageSourceOverride = null,
string? packageSourceOverridePattern = null,
CancellationToken cancellationToken = default)
{
var integrationList = integrations.ToList();
var packageRefs = integrationList.Where(r => r.Source == IntegrationSource.Nuget).ToList();
var projectRefs = integrationList.Where(r => r.Source == IntegrationSource.Project).ToList();
var npmIntegrations = integrationList.Where(r => r.Source == IntegrationSource.Npm).ToList();
// Lifted to outer scope so the failure footer reflects the source actually used by
// restore, including an auto-discovered local hive, rather than only the --source value
// originally passed in.
var effectivePackageSourceOverride = packageSourceOverride;
try
{
_selectedProjectLayout = null;
_contentRootPath = _workingDirectory;
_integrationLibsPath = null;
_integrationProbeManifestPath = null;
_supportsNativeDashboard = false;
_supportsDirectTerminalHost = false;
// Resolve the channel the project requests for restore (aspire.config.json#channel,
// with a legacy .aspire/settings.json#channel fallback). This is independent of the
// running CLI's identity hive (CliExecutionContext.IdentityChannel).
requestedChannel ??= ResolveRequestedChannel();
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.");
}
}
IntegrationRestorePlan? restorePlan = null;
if (packageRefs.Count > 0 || projectRefs.Count > 0)
{
restorePlan = await ResolveIntegrationRestorePlanAsync(
sdkVersion,
requestedChannel,
effectivePackageSourceOverride,
packageSourceOverridePattern,
cancellationToken).ConfigureAwait(false);
effectivePackageSourceOverride = restorePlan.EffectivePackageSourceOverride;
}
if (packageRefs.Count > 0 && projectRefs.Count == 0)
{
_integrationProbeManifestPath = await RestoreNuGetPackagesAsync(
packageRefs,
restorePlan!,
cancellationToken).ConfigureAwait(false);
}
if (projectRefs.Count > 0)
{
using var fileLock = await FileLock.AcquireAsync(_projectReferencePrepareLockPath, cancellationToken).ConfigureAwait(false);
_projectLayoutStore.CleanupStagingDirectories();
var closureManifest = await BuildIntegrationClosureManifestAsync(
packageRefs,
projectRefs,
sdkVersion,
restorePlan!,
npmIntegrations,
cancellationToken).ConfigureAwait(false);
if (closureManifest.Entries.Any(static entry => entry.IsPackageBacked))
{
_integrationProbeManifestPath = Path.Combine(_workingDirectory, IntegrationPackageProbeManifest.FileName);
await IntegrationPackageProbeManifest.WriteAsync(
_integrationProbeManifestPath,
closureManifest.CreatePackageProbeManifest(),
cancellationToken).ConfigureAwait(false);
}
_selectedProjectLayout = await _projectLayoutStore.GetOrCreateAsync(closureManifest, cancellationToken).ConfigureAwait(false);
if (_selectedProjectLayout is not null)
{
_integrationLibsPath = _selectedProjectLayout.IntegrationLibsPath;
}
await WriteAppSettingsAsync(_workingDirectory, closureManifest.AppSettingsContent, cancellationToken).ConfigureAwait(false);
}
else
{
var appSettingsContent = CreateAppSettingsContent(packageRefs, [], npmIntegrations);
await WriteAppSettingsAsync(_workingDirectory, appSettingsContent, cancellationToken).ConfigureAwait(false);
}
// Hosting before 13.6 treats every non-aspire-managed path as a managed Dashboard DLL.
// Use the restored Hosting version: the server bundle does not contain its own Hosting assembly.
_supportsNativeDashboard = TryGetHostingVersion(sdkVersion, out var hostingVersion) &&
DashboardLaunchHelper.SupportsNativeDashboard(hostingVersion);
_supportsDirectTerminalHost = TerminalHostLaunchHelper.SupportsDirectLaunch(hostingVersion);
return new AppHostServerPrepareResult(
Success: true,
Output: null,
ChannelName: requestedChannel,
NeedsCodeGeneration: true);
}
catch (OperationCanceledException)
{
throw;
}
catch (AppHostServerPrepareFailedException ex)
{
_logger.LogError(ex, "Failed to prepare prebuilt AppHost server");
AppendRestoreContextOnFailure(ex.Output, requestedChannel, effectivePackageSourceOverride, packageRefs);
return new AppHostServerPrepareResult(
Success: false,
Output: ex.Output,
ChannelName: requestedChannel,
NeedsCodeGeneration: false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to prepare prebuilt AppHost server");
var output = new OutputCollector();
output.AppendError($"Failed to prepare: {ex.Message}");
AppendRestoreContextOnFailure(output, requestedChannel, effectivePackageSourceOverride, packageRefs);
return new AppHostServerPrepareResult(
Success: false,
Output: output,
ChannelName: requestedChannel,
NeedsCodeGeneration: false);
}
}
// Augment the failure output with the source / channel / requested versions so a user looking
// at the displayed error after `aspire new --source <X>` can immediately see which inputs were
// in play, instead of having to re-run with diagnostic logging. Called from both prepare
// failure paths so every restore failure surfaces the same context shape.
private static void AppendRestoreContextOnFailure(
OutputCollector output,
string? requestedChannel,
string? packageSourceOverride,
IReadOnlyList<IntegrationReference> packageRefs)
{
var hasOverride = !string.IsNullOrWhiteSpace(packageSourceOverride);
var hasChannel = !string.IsNullOrEmpty(requestedChannel);
if (!hasOverride && !hasChannel)
{
return;
}
if (hasOverride)
{
// NuGet feed URLs commonly embed credentials in UserInfo
// (https://name:pat@host/...) or as SAS-style tokens in the query string.
// This line ends up in the output users copy into bug reports and CI
// transcripts, so strip the credential-carrying components before display.
output.AppendError($" --source: {RedactSourceForDisplay(packageSourceOverride!)}");
}
if (hasChannel)
{
output.AppendError($" channel: {requestedChannel}");
}
if (packageRefs.Count > 0)
{
var preview = packageRefs.Take(5).Select(static r => $"{r.Name} {r.Version}");
output.AppendError($" packages: {string.Join(", ", preview)}{(packageRefs.Count > 5 ? $", … (+{packageRefs.Count - 5} more)" : string.Empty)}");
}
}
/// <summary>
/// Restores NuGet packages using the bundled NuGet service (no .NET SDK required).
/// </summary>
private async Task<string> RestoreNuGetPackagesAsync(
List<IntegrationReference> packageRefs,
IntegrationRestorePlan restorePlan,
CancellationToken cancellationToken)
{
_logger.LogDebug("Restoring {Count} integration packages via bundled NuGet", packageRefs.Count);
var packages = packageRefs
.Select(r => (r.Name, Version: restorePlan.GetRestoreVersion(r.Name, r.Version!)))
.ToList();
using var restoreConfiguration = await restorePlan.CreatePackageRestoreConfigurationAsync(
cancellationToken).ConfigureAwait(false);
return await _nugetService.RestorePackagesAsync(
packages,
workingDirectory: _appDirectoryPath,
targetFramework: DotNetBasedAppHostServerProject.TargetFramework,
runtimeIdentifier: RuntimeInformation.RuntimeIdentifier,
sources: restoreConfiguration.Sources,
nugetConfigPaths: restoreConfiguration.ConfigPaths,
nugetSettingsCacheIdentity: restoreConfiguration.SettingsCacheIdentity,
nugetConfigOverlayCacheIdentity: restoreConfiguration.OverlayCacheIdentity,
additionalSensitiveSources: restoreConfiguration.SensitiveSources,
globalPackagesFolderOverride: restoreConfiguration.GlobalPackagesFolder,
ct: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Writes <paramref name="content" /> only when it differs from what is already on disk.
/// </summary>
/// <remarks>
/// Rewriting an identical file still updates its timestamp, which MSBuild treats as a changed
/// input and responds to by rebuilding. Writing only on a real change keeps the incremental
/// build intact across launches.
/// </remarks>
internal static Task WriteIfChangedAsync(string path, string content, CancellationToken cancellationToken)
=> GeneratedFileWriter.WriteIfChangedAsync(path, content, cancellationToken);
/// <summary>
/// Produces the failure message for a failed integration build, recognizing an
/// Aspire.Hosting package downgrade as a configuration problem rather than a build problem.
/// </summary>
/// <remarks>
/// The AppHost server is the CLI itself, so the synthesized project pins Aspire.Hosting to the
/// selected AppHost SDK version. A project reference that requires a newer Aspire.Hosting cannot
/// be satisfied, and NuGet reports it as a downgrade:
/// error NU1605: Warning As Error: Detected package downgrade: Aspire.Hosting from 13.6.0-dev to 13.5.0
/// MSBuild localizes the diagnostic text, but the error code and package ID remain stable.
/// </remarks>
internal static string GetIntegrationBuildFailureMessage(OutputCollector buildOutput)
{
var hasAspireHostingPackageDowngrade = buildOutput.GetLines()
.Any(static l =>
l.Line.Contains("NU1605", StringComparison.Ordinal) &&
l.Line.Contains(" Aspire.Hosting ", StringComparison.OrdinalIgnoreCase));
return hasAspireHostingPackageDowngrade
? string.Format(
CultureInfo.CurrentCulture,
ErrorStrings.IntegrationBuildPackageDowngradeFailed,
VersionHelper.GetDefaultTemplateVersion())
: ErrorStrings.IntegrationBuildFailed;
}
private async Task<(int ExitCode, OutputCollector Output)> BuildIntegrationProjectAsync(
string projectFilePath,
bool noRestore,
string? globalPackagesFolder,
string integrationHostingVersion,
string? integrationPackageSources,
string? integrationPackageSourceAlias,
CancellationToken cancellationToken)
{
var buildOutput = new OutputCollector();
// Environment-backed MSBuild properties are visible during NuGet's restore graph
// evaluation, including Directory.Packages.props.
var environmentVariables = new Dictionary<string, string>
{
[IntegrationHostingVersionPropertyName] = integrationHostingVersion
};
if (globalPackagesFolder is not null)
{
environmentVariables[CliPathHelper.NuGetPackagesEnvironmentVariable] = globalPackagesFolder;
}
if (integrationPackageSources is not null)
{
environmentVariables[IntegrationPackageSourcesPropertyName] = integrationPackageSources;
}
if (integrationPackageSourceAlias is not null)
{
environmentVariables[IntegrationPackageSourceAliasPropertyName] = integrationPackageSourceAlias;
}
var exitCode = await _dotNetCliRunner.BuildAsync(
new FileInfo(projectFilePath),
noRestore,
new ProcessInvocationOptions
{
StandardOutputCallback = buildOutput.AppendOutput,
StandardErrorCallback = buildOutput.AppendError,
EnvironmentVariableFilter = name =>
string.Equals(name, IntegrationHostingVersionPropertyName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, IntegrationPackageSourcesPropertyName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, IntegrationPackageSourceAliasPropertyName, StringComparison.OrdinalIgnoreCase) ||
(globalPackagesFolder is not null &&
string.Equals(name, CliPathHelper.NuGetPackagesEnvironmentVariable, StringComparison.OrdinalIgnoreCase)),
EnvironmentVariables = environmentVariables,
// Referenced projects can discover credential-bearing sources that are not known until
// NuGet evaluates the complete restore graph. Buffer the process output without logging
// it so failures can be redacted against that evaluated graph before diagnostics escape.
SuppressLogging = true
},
cancellationToken).ConfigureAwait(false);
return (exitCode, buildOutput);
}
private async Task<OutputCollector> RedactIntegrationBuildFailureOutputAsync(
string projectFilePath,
string intermediateOutputPath,
OutputCollector rawOutput,
IReadOnlyList<string> knownSensitiveSources,
string failureMessage,
CancellationToken cancellationToken)
{
var dependencyGraphSpecPath = Path.Combine(
intermediateOutputPath,
$"{Path.GetFileName(projectFilePath)}.nuget.dgspec.json");
var evaluatedSources = await TryReadDependencyGraphSourcesAsync(
dependencyGraphSpecPath,
cancellationToken).ConfigureAwait(false);
if (evaluatedSources is null)
{
var safeOutput = new OutputCollector();
safeOutput.AppendError(failureMessage);
return safeOutput;
}
var sensitiveSources = knownSensitiveSources
.Concat(evaluatedSources.Where(PackageSourceOverrideMappings.HasCredentialMaterial))
.Distinct(StringComparer.Ordinal)
.ToArray();
var redactedOutput = new OutputCollector();
foreach (var (stream, line) in rawOutput.GetLines())
{
var redactedLine = PackageSourceRedactor.RedactOccurrences(line, sensitiveSources);
if (stream == OutputLineStream.StdOut)
{
redactedOutput.AppendOutput(redactedLine);
_logger.LogTrace("Build output: {Output}", redactedLine);
}
else
{
redactedOutput.AppendError(redactedLine);
_logger.LogTrace("Build error: {Error}", redactedLine);
}
}
return redactedOutput;
}
private async Task<IReadOnlyList<string>?> TryReadDependencyGraphSourcesAsync(
string dependencyGraphSpecPath,
CancellationToken cancellationToken)
{
try
{
await using var stream = File.OpenRead(dependencyGraphSpecPath);
using var document = await JsonDocument.ParseAsync(
stream,
cancellationToken: cancellationToken).ConfigureAwait(false);
// NuGet writes one entry per evaluated project using this shape:
// { "projects": { "<project path>": { "restore": { "sources": { "<source>": {} } } } } }
// Source URLs are JSON property names, and transitive project references can contribute
// entries that were not discoverable from the generated root project's settings.
if (!document.RootElement.TryGetProperty("projects", out var projects) ||
projects.ValueKind != JsonValueKind.Object)
{
_logger.LogDebug(
"Integration restore dependency graph {DependencyGraphSpecPath} did not contain project metadata. Detailed build output will be omitted.",
dependencyGraphSpecPath);
return null;
}
var sources = new HashSet<string>(StringComparer.Ordinal);
foreach (var project in projects.EnumerateObject())
{
if (!project.Value.TryGetProperty("restore", out var restore) ||
restore.ValueKind != JsonValueKind.Object ||
!restore.TryGetProperty("sources", out var projectSources) ||
projectSources.ValueKind != JsonValueKind.Object)
{
_logger.LogDebug(
"Integration restore dependency graph {DependencyGraphSpecPath} did not contain source metadata for project {ProjectPath}. Detailed build output will be omitted.",
dependencyGraphSpecPath,
project.Name);
return null;
}
foreach (var source in projectSources.EnumerateObject())
{
sources.Add(source.Name);
}
}
return sources.ToArray();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
_logger.LogDebug(
ex,
"Integration restore dependency graph {DependencyGraphSpecPath} could not be read. Detailed build output will be omitted.",
dependencyGraphSpecPath);
return null;
}
}
/// <summary>
/// Creates a synthetic .csproj with the integration project references,
/// then builds it to get their full copied-local closure.
/// Requires .NET SDK.
/// </summary>
private async Task<AppHostServerClosureManifest> BuildIntegrationClosureManifestAsync(
List<IntegrationReference> packageRefs,
List<IntegrationReference> projectRefs,
string sdkVersion,
IntegrationRestorePlan restorePlan,
List<IntegrationReference> npmIntegrations,
CancellationToken cancellationToken)
{
var restoreDir = Path.Combine(_workingDirectory, "integration-restore");
Directory.CreateDirectory(restoreDir);
var policyDirectory = IntegrationClosureBuilder.GetAppHostIntegrationPolicyDirectory(
new DirectoryInfo(_appDirectoryPath));
var restoreConfiguration = restorePlan.ApplyProjectRestoreConfiguration(
policyDirectory,
cancellationToken);
var integrationPackageSources = IntegrationClosureBuilder.CreateRestoreAdditionalProjectSourcesValue(
existingValue: null,
restoreConfiguration.PackageSourceHints);
var intermediateOutputPath = Path.Combine(restoreDir, "obj");
var resolvedPackageRefs = packageRefs
.Select(packageRef => (
packageRef.Name,
Version: restorePlan.GetRestoreVersion(packageRef.Name, packageRef.Version!)))
.ToList();
if (!resolvedPackageRefs.Any(static packageRef =>
packageRef.Name.Equals("Aspire.Hosting", StringComparison.OrdinalIgnoreCase)))
{
resolvedPackageRefs.Insert(
0,
("Aspire.Hosting", restorePlan.GetRestoreVersion("Aspire.Hosting", sdkVersion)));
}
var projectContent = GenerateIntegrationProjectFile(
resolvedPackageRefs,
projectRefs,
restoreDir,
restoreConfiguration.RootAdditionalSources);
var projectFilePath = Path.Combine(restoreDir, IntegrationProjectFileName);
await WriteIfChangedAsync(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 WriteIfChangedAsync(
Path.Combine(restoreDir, "Directory.Packages.props"), directoryPackagesProps, cancellationToken);
// Directory.Build.props sets output paths before the SDK consumes them and also prevents
// parent props from affecting the generated project.
await WriteIfChangedAsync(
Path.Combine(restoreDir, "Directory.Build.props"),
IntegrationClosureBuilder.CreateClosureDirectoryBuildProps(
restoreDir,
intermediateOutputPath,
restoreConfiguration.RestoreRootConfigDirectory,
globalPackagesFolder: null).ToString(),
cancellationToken);
// Write empty Directory.Build.targets to prevent parent targets imports.
await WriteIfChangedAsync(
Path.Combine(restoreDir, "Directory.Build.targets"), "<Project />", cancellationToken);
// Restore graph generation can fail before NuGet replaces a graph from a previous build.
// Remove it first so failure redaction never trusts stale source metadata when deciding
// whether the current build output is safe to replay.
var dependencyGraphSpecPath = Path.Combine(
intermediateOutputPath,
$"{Path.GetFileName(projectFilePath)}.nuget.dgspec.json");
if (File.Exists(dependencyGraphSpecPath))
{
File.Delete(dependencyGraphSpecPath);
}
_logger.LogDebug("Building integration project with {ProjectCount} project references", projectRefs.Count);
var (exitCode, buildOutput) = await BuildIntegrationProjectAsync(
projectFilePath,
noRestore: false,
restoreConfiguration.GlobalPackagesFolder,
integrationHostingVersion: sdkVersion,
integrationPackageSources,
restoreConfiguration.PackageSourceAlias,
cancellationToken).ConfigureAwait(false);
if (exitCode != 0)
{
var failureMessage = GetIntegrationBuildFailureMessage(buildOutput);
buildOutput = await RedactIntegrationBuildFailureOutputAsync(
projectFilePath,
intermediateOutputPath,
buildOutput,
restoreConfiguration.SensitiveSources,
failureMessage,
cancellationToken).ConfigureAwait(false);
var outputLines = string.Join(Environment.NewLine, buildOutput.GetLines().Select(l => l.Line));
_logger.LogError("Integration project build failed. Output:\n{BuildOutput}", outputLines);
throw new AppHostServerPrepareFailedException(failureMessage, buildOutput);
}
var projectRefAssemblyNames = await IntegrationClosureBuilder.ReadProjectRefAssemblyNamesAsync(
restoreDir,
_logger,
cancellationToken).ConfigureAwait(false);
var appSettingsContent = CreateAppSettingsContent(packageRefs, projectRefAssemblyNames, npmIntegrations);
var closureManifest = await IntegrationClosureBuilder.ReadClosureManifestAsync(
restoreDir,
Path.Combine(intermediateOutputPath, IntegrationClosureBuilder.ProjectAssetsFileName),
appSettingsContent,
ClosureFileMissingBehavior.Throw,
_logger,
cancellationToken).ConfigureAwait(false);
// ReadClosureManifestAsync only returns null in ReturnNull mode; in Throw mode any
// missing/inconsistent state has already raised an exception.
Debug.Assert(closureManifest is not null);
await File.WriteAllLinesAsync(
Path.Combine(restoreDir, ClosureManifestFileName),
closureManifest!.GetManifestLines(),
cancellationToken).ConfigureAwait(false);
return closureManifest;
}
/// <summary>
/// Generates a synthetic .csproj file containing every integration package and project reference.
/// Building this project with CopyLocalLockFileAssemblies produces one NuGet-resolved closure.
/// </summary>
internal static string GenerateIntegrationProjectFile(
IReadOnlyList<(string Name, string Version)> packageRefs,
List<IntegrationReference> projectRefs,
string restoreDir,
IEnumerable<string>? additionalSources = null)
{
ArgumentNullException.ThrowIfNull(packageRefs);
var projectFile = IntegrationClosureBuilder.CreateClosureProjectFile(
restoreDir,
additionalSources);
// Without a root RID, ReferenceCopyLocalPaths includes native assets for every RID in a
// package. A project-local property selects the host assets without flowing to project references.
projectFile.AddProperty("RuntimeIdentifier", RuntimeInformation.RuntimeIdentifier);
foreach (var packageRef in packageRefs)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packageRef.Name);
ArgumentException.ThrowIfNullOrWhiteSpace(packageRef.Version);
projectFile.PackageReferences.Add(new CSharpPackageReference(
packageRef.Name,
packageRef.Version));
}
projectFile.ProjectReferences.AddRange(projectRefs.Select(p => new CSharpProjectReference(
p.Path!,
IsAspireProjectResource: false,
ReferenceOutputAssembly: true)));
return projectFile.ToXDocument().ToString();
}
/// <summary>
/// Resolves the channel name the <em>project requests</em> for restore — read from the
/// project's <c>aspire.config.json#channel</c> (or legacy <c>.aspire/settings.json#channel</c>).
/// This is independent of the running CLI's <see cref="CliExecutionContext.IdentityChannel"/>.
/// </summary>
internal string? ResolveRequestedChannel()
{
// Check aspire.config.json first, then fall back to legacy .aspire/settings.json.
var channelName = AspireConfigFile.Load(_appDirectoryPath)?.Channel
?? AspireJsonConfiguration.Load(_appDirectoryPath)?.Channel;
if (!string.IsNullOrEmpty(channelName))
{
_logger.LogDebug("Resolved channel: {Channel}", channelName);
}
return channelName;
}
internal Task<IntegrationRestorePlan> ResolveIntegrationRestorePlanAsync(
string sdkVersion,
string? requestedChannel,
string? packageSourceOverride,
string? packageSourceOverridePattern,
CancellationToken cancellationToken)
=> new IntegrationRestorePlanResolver(
_packagingService,
_nugetService,
_executionContext,
_logger)
.ResolveAsync(
_appDirectoryPath,
_workloadId,
sdkVersion,
requestedChannel,
packageSourceOverride,
packageSourceOverridePattern,
cancellationToken);
// Display-safe form of a NuGet source used in user-visible error footers. Delegates to the
// shared helper so the same redaction is applied wherever sources appear (failure context,
// debug logs in BundleNuGetService, etc.).
internal static string RedactSourceForDisplay(string source) => PackageSourceRedactor.RedactForDisplay(source);
/// <inheritdoc />
public async Task<AppHostServerRunResult> RunAsync(
int hostPid,
IReadOnlyDictionary<string, string>? environmentVariables,
string[]? additionalArgs,
bool debug,
AppHostServerRunControl? runControl)
{
var startInfo = CreateStartInfo(hostPid, environmentVariables, additionalArgs, debug);
var outputCollector = new OutputCollector();
// The execution local is forward-referenced by the log callbacks so they can read the
// child's pid per line (ProcessInvocationOptions.StandardOutputCallback is line-only). The
// log level + prefix differ from the dotnet-based server (#16729); keeping them here keeps
// this server's per-line behavior in one place. ProcessExecution publishes the child pid before
// it starts stdout/stderr pumps so immediate output can read ProcessId.
IProcessExecution execution = null!;
void OnStdout(string line)
{
// Debug retains raw server output in the default CLI file log without
// printing routine diagnostics during normal usage.
_logger.LogDebug("PrebuiltAppHostServer({ProcessId}) stdout: {Line}", execution.ProcessId, line);
outputCollector.AppendOutput(line);
}
void OnStderr(string line)
{
// The stream does not establish severity. Startup failures surface the
// collected output separately at Error.
_logger.LogDebug("PrebuiltAppHostServer({ProcessId}) stderr: {Line}", execution.ProcessId, line);
outputCollector.AppendError(line);
}
var options = new ProcessInvocationOptions
{
StandardOutputCallback = OnStdout,
StandardErrorCallback = OnStderr,
IsolateConsole = runControl?.IsolateConsole ?? false,
KillOnParentExit = runControl?.KillOnParentExit ?? false,
Lifetime = ChildProcessLifetime.AppHost,
GracefulShutdownSignaler = runControl?.GracefulShutdownSignaler,
ShutdownService = runControl?.ShutdownService,
KillEntireProcessTreeOnCancel = !_environment.IsWindows(),
};
execution = _processExecutionFactory.CreateExecution(startInfo, options);
try
{
await execution.StartAsync(CancellationToken.None).ConfigureAwait(false);
}
catch
{
await execution.DisposeAsync().ConfigureAwait(false);
throw;
}
return new AppHostServerRunResult(_socketPath, outputCollector, execution);
}
internal ProcessStartInfo CreateStartInfo(
int hostPid,
IReadOnlyDictionary<string, string>? environmentVariables = null,
string[]? additionalArgs = null,
bool debug = false)
{
var serverPath = GetServerPath();
var contentRootPath = _contentRootPath ?? _workingDirectory;
var startInfo = new ProcessStartInfo(serverPath)
{
WorkingDirectory = contentRootPath,
WindowStyle = ProcessWindowStyle.Minimized,
UseShellExecute = false,
CreateNoWindow = true
};
// Insert "server" subcommand, then remaining args
startInfo.ArgumentList.Add("server");
startInfo.ArgumentList.Add("--contentRoot");
startInfo.ArgumentList.Add(contentRootPath);
// 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[KnownConfigNames.CliLogFilePath] = _executionContext.LogFilePath;
// Stamp the launching CLI (hostPid) as the parent under both the RemoteHost and generic CLI
// key pairs. Resolve the start time once and pair it with the PID so the RemoteHost orphan
// detector verifies both and does not keep the server alive against a recycled PID.
var hostStartedUnix = ProcessStartTimeHelper.TryGetProcessStartTimeUnixMilliseconds(hostPid);
OrphanDetectionEnvironment.Apply(startInfo.Environment, hostPid, hostStartedUnix, KnownConfigNames.RemoteAppHostProcessId, KnownConfigNames.RemoteAppHostProcessStarted);
OrphanDetectionEnvironment.Apply(startInfo.Environment, hostPid, hostStartedUnix, KnownConfigNames.CliProcessId, KnownConfigNames.CliProcessStarted);
IntegrationClosureEnvironment.Apply(
(key, value) => startInfo.Environment[key] = value,
key => startInfo.Environment.Remove(key),
_integrationProbeManifestPath,
_integrationLibsPath,
_logger);
// Set DCP and Dashboard paths from the layout
var dcpPath = _layout.GetDcpPath();
if (dcpPath is not null)
{
startInfo.Environment[BundleDiscovery.DcpPathEnvVar] = dcpPath;
}
else
{
// Without this variable the AppHost falls back to the DcpCliPath assembly metadata baked in
// by the AppHost SDK, which points into ~/.nuget/packages. A guest-language AppHost never
// restores that package, so the run fails with "The Aspire orchestration component is not
// installed at <nuget path>" - a message that describes the fallback rather than the real
// problem, which is that no layout supplied DCP. Log the real cause where the CLI logs are.
_logger.LogWarning(
"No layout supplied a DCP path, so {EnvironmentVariable} was not set. The AppHost will fall back to its baked-in NuGet package path, which a guest-language AppHost does not restore.",
BundleDiscovery.DcpPathEnvVar);
}
if (DashboardLaunchHelper.GetDashboardPath(_layout, _supportsNativeDashboard) is { } dashboardPath)
{
startInfo.Environment[BundleDiscovery.DashboardPathEnvVar] = dashboardPath;
}
// Set explicit terminal host defaults so they remain valid when the Dashboard path is overridden.
// Retain inherited and apphost.run.json overrides as a pair.
bool HasEnvironmentOverride(string name)
{
var value = environmentVariables is not null && environmentVariables.TryGetValue(name, out var configuredValue)
? configuredValue
: startInfo.Environment.TryGetValue(name, out var inheritedValue) ? inheritedValue : null;
return !string.IsNullOrWhiteSpace(value);
}
if (!HasEnvironmentOverride(BundleDiscovery.TerminalHostPathEnvVar) &&
TerminalHostLaunchHelper.GetLaunch(_layout, _supportsDirectTerminalHost) is { } terminalHost)
{
startInfo.Environment[BundleDiscovery.TerminalHostPathEnvVar] = terminalHost.Path;
if (environmentVariables?.ContainsKey(BundleDiscovery.TerminalHostInvocationArgsEnvVar) != true &&
!startInfo.Environment.ContainsKey(BundleDiscovery.TerminalHostInvocationArgsEnvVar))
{
startInfo.Environment[BundleDiscovery.TerminalHostInvocationArgsEnvVar] = terminalHost.InvocationArgs;
}
}
// Apply environment variables from apphost.run.json
if (environmentVariables is not null)
{
foreach (var (key, value) in environmentVariables)
{
startInfo.Environment[key] = value;
}
}
_layoutLease?.AddEnvironment(startInfo);
if (debug)
{
startInfo.Environment[KnownConfigNames.AspireLogLevel] = "Debug";
}
startInfo.RedirectStandardInput = true;
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
return startInfo;
}
/// <inheritdoc />
public string GetInstanceIdentifier() => _appDirectoryPath;
/// <inheritdoc />
public void Dispose()
{
_layoutLease?.Dispose();
}
private static string CreateAppSettingsContent(
List<IntegrationReference> packageRefs,
List<string> projectRefAssemblyNames,
List<IntegrationReference> npmIntegrations)
{
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 appSettingsJson = AppHostServerAppSettingsWriter.Generate(atsAssemblies, npmIntegrations);
return appSettingsJson;
}
private static async Task WriteAppSettingsAsync(string contentRootPath, string appSettingsContent, CancellationToken cancellationToken)
{
Directory.CreateDirectory(contentRootPath);
await File.WriteAllTextAsync(
Path.Combine(contentRootPath, "appsettings.json"),
appSettingsContent,
cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Represents a prebuilt AppHost preparation failure with captured build output.
/// </summary>
private sealed class AppHostServerPrepareFailedException(string message, OutputCollector output) : Exception(message)
{
public OutputCollector Output { get; } = output;
}
}