// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Buffers;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO.Hashing;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
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 partial 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";
private const string ProjectAssetsFileName = "project.assets.json";
private const string RestoreStampFileName = "aspire-restore.stamp";
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 _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>
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)
{
_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;
_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,
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 the auto-discovered local hive resolved by
// ResolveLocalPackageSourceOverrideAsync — rather than the unset --source the user
// 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 (string.IsNullOrWhiteSpace(effectivePackageSourceOverride))
{
effectivePackageSourceOverride = await ResolveLocalPackageSourceOverrideAsync(requestedChannel, cancellationToken).ConfigureAwait(false);
}
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.");
}
using var fileLock = await FileLock.AcquireAsync(_projectReferencePrepareLockPath, cancellationToken).ConfigureAwait(false);
_projectLayoutStore.CleanupStagingDirectories();
var closureManifest = await BuildIntegrationClosureManifestAsync(
packageRefs,
projectRefs,
npmIntegrations,
requestedChannel,
effectivePackageSourceOverride,
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
{
if (packageRefs.Count > 0)
{
// NuGet-only — use the bundled NuGet service (no SDK required)
_integrationProbeManifestPath = await RestoreNuGetPackagesAsync(
packageRefs, requestedChannel, effectivePackageSourceOverride, cancellationToken);
}
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,
string? requestedChannel,
string? packageSourceOverride,
CancellationToken cancellationToken)
{
_logger.LogDebug("Restoring {Count} integration packages via bundled NuGet", packageRefs.Count);
var useExactPackageVersions = !string.IsNullOrWhiteSpace(packageSourceOverride);
var packages = packageRefs
.Select(r => (r.Name, Version: GetRestoreVersion(r.Name, r.Version!, useExactPackageVersions)))
.ToList();
using var temporaryNuGetConfig = await TryCreateTemporaryNuGetConfigAsync(requestedChannel, packageSourceOverride, cancellationToken);
var sources = await GetNuGetSourcesAsync(requestedChannel, packageSourceOverride, cancellationToken);
return await _nugetService.RestorePackagesAsync(
packages,
workingDirectory: _appDirectoryPath,
targetFramework: DotNetBasedAppHostServerProject.TargetFramework,
runtimeIdentifier: RuntimeInformation.RuntimeIdentifier,
sources: sources,
nugetConfigPath: temporaryNuGetConfig?.ConfigFile.FullName,
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>
/// Reads every restore input, returning its fingerprint and whether the closure is eligible
/// for a skipped restore at all.
/// </summary>
/// <remarks>
/// The generated project file encodes package identities and versions, project reference paths,
/// channel sources, and the synthesized NuGet.config path. Referenced project files are hashed as
/// well because restore resolves their dependencies too: a referenced project bumping its own
/// Aspire.Hosting version changes the resolved closure without changing a single byte of the
/// generated project file.
/// <para>
/// The whole project-reference graph is walked, not just its first level, because restore
/// resolves the graph: a package bump two hops out changes the closure exactly as much as one
/// hop out does. Each project's directory-scoped MSBuild imports are hashed with it, since under
/// central package management the reference carries no version at all and bumping
/// Directory.Packages.props changes what restore resolves while every project file stays
/// byte-for-byte identical.
/// </para>
/// <para>
/// Every project in that closure is also scanned for floating versions, because a float anywhere
/// in it can resolve to a different package without any local input changing.
/// </para>
/// </remarks>
internal static async Task<RestoreInputs> ComputeRestoreInputsAsync(
string projectContent,
IReadOnlyList<IntegrationReference> packageRefs,
IReadOnlyList<IntegrationReference> projectRefs,
CancellationToken cancellationToken)
{
var hash = new XxHash3();
hash.Append(Encoding.UTF8.GetBytes(projectContent));
var isFloating = HasFloatingPackageVersion(packageRefs);
var pending = new Queue<string>();
// Ordinal rather than a path-aware comparer: a duplicate spelling of the same path costs one
// extra read, whereas treating two genuinely different paths as one would drop an input.
var visited = new HashSet<string>(StringComparer.Ordinal);
var closure = new List<string>();
foreach (var projectRef in projectRefs)
{
if (projectRef.Path is { } path)
{
pending.Enqueue(path);
}
}
while (pending.Count > 0)
{
var projectPath = pending.Dequeue();
var normalizedPath = NormalizeProjectPath(projectPath);
// Terminates on its own rather than hanging the launch: MSBuild rejects a project
// reference cycle, but the fingerprint is computed before anything validates the graph.
if (!visited.Add(normalizedPath))
{
continue;
}
closure.Add(normalizedPath);
foreach (var referenced in ReadProjectReferences(normalizedPath))
{
pending.Enqueue(referenced);
}
}
// Ordering makes the fingerprint independent of the order the graph happened to be walked in.
// Hash the path as well as the content so that repointing a reference at a different project
// with identical content is still seen as a change.
foreach (var projectPath in closure.OrderBy(static path => path, StringComparer.Ordinal))
{
hash.Append(Encoding.UTF8.GetBytes(projectPath));
if (!File.Exists(projectPath))
{
continue;
}
var projectBytes = await File.ReadAllBytesAsync(projectPath, cancellationToken).ConfigureAwait(false);
hash.Append(projectBytes);
if (!isFloating && HasFloatingVersionAttribute(Encoding.UTF8.GetString(projectBytes)))
{
isFloating = true;
}
}
foreach (var importPath in FindDirectoryScopedImports(closure).OrderBy(static path => path, StringComparer.Ordinal))
{
hash.Append(Encoding.UTF8.GetBytes(importPath));
var importBytes = await File.ReadAllBytesAsync(importPath, cancellationToken).ConfigureAwait(false);
hash.Append(importBytes);
if (!isFloating && HasFloatingVersionAttribute(Encoding.UTF8.GetString(importBytes)))
{
isFloating = true;
}
}
return new RestoreInputs(Convert.ToHexString(hash.GetCurrentHash()), IsEligibleForSkip: !isFloating);
}
/// <summary>
/// Resolves a project path to a comparable absolute form so the same project reached by two
/// different spellings is hashed once.
/// </summary>
private static string NormalizeProjectPath(string projectPath)
{
try
{
return Path.GetFullPath(projectPath);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
// An unresolvable path is still hashed verbatim: it cannot be read, but the fact that the
// closure names it is itself an input, and a later change to a valid path is then seen.
return projectPath;
}
}
/// <summary>
/// Reads the <ProjectReference Include="..." /> paths a project declares, resolved against
/// the project's own directory the way MSBuild resolves them.
/// </summary>
/// <remarks>
/// Parsed as XML rather than with a regex because an Include can be spread across attributes and
/// whitespace. A project that cannot be read or parsed contributes no references: the file itself
/// is still hashed above, so a later fix to it changes the fingerprint.
/// </remarks>
private static List<string> ReadProjectReferences(string projectPath)
{
var references = new List<string>();
if (!File.Exists(projectPath))
{
return references;
}
XDocument document;
try
{
using var stream = File.OpenRead(projectPath);
// DTD processing stays off: these files are inputs from the user's checkout and an
// external entity must never be fetched while computing a fingerprint.
using var reader = XmlReader.Create(stream, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null });
document = XDocument.Load(reader);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or XmlException)
{
return references;
}
var projectDirectory = Path.GetDirectoryName(projectPath);
if (projectDirectory is null)
{
return references;
}
foreach (var element in document.Descendants().Where(static e => e.Name.LocalName == "ProjectReference"))
{
var include = element.Attribute("Include")?.Value;
if (string.IsNullOrWhiteSpace(include))
{
continue;
}
// An MSBuild property in the path ($(RepoRoot)/...) cannot be expanded without evaluating
// the project, so the reference is skipped rather than hashed under a nonsense path.
if (include.Contains("$(", StringComparison.Ordinal))
{
continue;
}
references.Add(Path.Combine(projectDirectory, include.Replace('\\', Path.DirectorySeparatorChar)));
}
return references;
}
/// <summary>
/// Finds the directory-scoped files MSBuild and NuGet import automatically for the projects in a
/// closure, by walking from each project's directory to the root the way they do.
/// </summary>
/// <remarks>
/// These carry version information that never appears in the project file itself - most
/// importantly Directory.Packages.props under central package management, where the reference is
/// written without a version at all.
/// <list type="bullet">
/// <item>https://learn.microsoft.com/nuget/consume-packages/central-package-management</item>
/// <item>https://learn.microsoft.com/visualstudio/msbuild/customize-by-directory</item>
/// </list>
/// </remarks>
private static HashSet<string> FindDirectoryScopedImports(IReadOnlyList<string> closure)
{
var imports = new HashSet<string>(StringComparer.Ordinal);
var scannedDirectories = new HashSet<string>(StringComparer.Ordinal);
foreach (var projectPath in closure)
{
var directory = Path.GetDirectoryName(projectPath);
while (directory is not null && scannedDirectories.Add(directory))
{
foreach (var fileName in s_directoryScopedImportFileNames)
{
var candidate = Path.Combine(directory, fileName);
if (File.Exists(candidate))
{
imports.Add(candidate);
}
}
directory = Path.GetDirectoryName(directory);
}
}
return imports;
}
// NuGet.config is matched case-insensitively by NuGet itself, but the two spellings below are the
// ones it documents and the ones repositories actually use.
private static readonly string[] s_directoryScopedImportFileNames =
[
"Directory.Packages.props",
"Directory.Build.props",
"Directory.Build.targets",
"NuGet.config",
"nuget.config"
];
/// <summary>
/// The restore inputs for one integration closure.
/// </summary>
/// <param name="Fingerprint">Identifies the exact set of inputs the restore reads.</param>
/// <param name="IsEligibleForSkip">
/// Whether an unchanged fingerprint is enough to prove the resolved closure is unchanged.
/// </param>
internal readonly record struct RestoreInputs(string Fingerprint, bool IsEligibleForSkip);
/// <summary>
/// Returns <see langword="true" /> when a project file declares a package version that NuGet
/// resolves against the feed rather than pinning exactly.
/// </summary>
/// <remarks>
/// Matches the version attribute of a reference, for example
/// <c><PackageReference Include="Aspire.Hosting" Version="13.4.*" /></c> or
/// <c>VersionOverride="[13.4,14)"</c>. The word boundary keeps unrelated attributes that merely
/// end in "Version" (such as <c>ToolsVersion</c>) from matching. A false positive only forces a
/// restore, which is the safe direction.
/// </remarks>
internal static bool HasFloatingVersionAttribute(string projectText)
=> FloatingVersionAttributeRegex().IsMatch(projectText);
[GeneratedRegex("""\b(?:VersionOverride|Version)\s*=\s*"[^"]*[*\[(,]""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex FloatingVersionAttributeRegex();
/// <summary>
/// Returns <see langword="true" /> when any package version can resolve to a different package
/// without any local input changing, which makes the closure ineligible for a skipped restore.
/// </summary>
/// <remarks>
/// A floating version ("13.4.*") or a range ("[13.4,14)") is resolved by NuGet at restore time
/// against the feed, so an unchanged fingerprint does not imply an unchanged closure.
/// </remarks>
internal static bool HasFloatingPackageVersion(IReadOnlyList<IntegrationReference> packageRefs)
=> packageRefs.Any(static r => r.Version is { } version && version.AsSpan().ContainsAny(s_floatingVersionChars));
// '*' is a float, and '[', '(', ',' delimit a version range. An exact version contains none of them.
private static readonly SearchValues<char> s_floatingVersionChars = SearchValues.Create("*[(,");
/// <summary>
/// Determines whether the last successful restore already saw this exact set of inputs.
/// </summary>
/// <remarks>
/// The stamp is written only after a restore succeeds, so its presence with a matching
/// fingerprint means a complete restore has run for these inputs. This is compared by content
/// rather than by timestamp because file modification times are unreliable across coarse
/// filesystems, clock skew, and caches that restore mtimes.
/// </remarks>
internal static bool CanSkipIntegrationRestore(string restoreDir, string expectedFingerprint, ILogger logger)
{
var assetsPath = Path.Combine(restoreDir, "obj", ProjectAssetsFileName);
var stampPath = Path.Combine(restoreDir, "obj", RestoreStampFileName);
if (!File.Exists(assetsPath) || !File.Exists(stampPath))
{
return false;
}
try
{
return string.Equals(File.ReadAllText(stampPath), expectedFingerprint, StringComparison.Ordinal);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogDebug(ex, "Unable to read the integration restore stamp; restoring.");
return false;
}
}
/// <summary>
/// Records that a restore completed successfully for <paramref name="fingerprint" />.
/// </summary>
private static async Task WriteRestoreStampAsync(string restoreDir, string fingerprint, ILogger logger, CancellationToken cancellationToken)
{
try
{
var objDir = Path.Combine(restoreDir, "obj");
Directory.CreateDirectory(objDir);
await File.WriteAllTextAsync(Path.Combine(objDir, RestoreStampFileName), fingerprint, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// A missing stamp only costs a restore on the next launch, so this is not worth failing over.
logger.LogDebug(ex, "Unable to write the integration restore stamp.");
}
}
/// <summary>
/// Returns <see langword="true" /> when a build failure looks like one that restoring would fix.
/// </summary>
/// <remarks>
/// Only a package-resolution failure is worth a second build. Retrying every failure would
/// double the cost of an ordinary compile error and would replace its diagnostic with whatever
/// the restore attempt produced.
/// The restore fingerprint covers this app's own inputs but cannot see the shared global package
/// cache, so a `dotnet nuget locals all --clear` (or any cache eviction) leaves the fingerprint
/// unchanged while the packages it assumes are gone. Because the stamp is only ever written
/// after a successful restore and is never cleared, a no-restore build that fails this way would
/// otherwise fail identically on every subsequent run until the user manually deleted obj/.
/// Examples of the failures this matches:
/// error NETSDK1004: Assets file '/path/obj/project.assets.json' not found. Run a NuGet package restore.
/// error NETSDK1064: Package Aspire.Hosting.Redis, version 13.5.0 was not found. It might have been deleted since NuGet restore.
/// error NU1101: Unable to find package Aspire.Hosting.Java. No packages exist with this id in source(s): dotnet-public
/// error NU1102: Unable to find package Aspire.Hosting with version (>= 13.6.0-dev)
/// </remarks>
internal static bool ShouldRetryWithRestore(OutputCollector buildOutput)
=> buildOutput.GetLines().Any(static l =>
l.Line.Contains("NETSDK1004", StringComparison.Ordinal) ||
l.Line.Contains("NETSDK1064", StringComparison.Ordinal) ||
l.Line.Contains("NU1101", StringComparison.Ordinal) ||
l.Line.Contains("NU1102", StringComparison.Ordinal) ||
l.Line.Contains(ProjectAssetsFileName, StringComparison.Ordinal));
/// <summary>
/// Produces the failure message for a failed integration build, recognizing the one failure
/// mode that is 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
/// CLI's own 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
/// The raw output is unusable here because MSBuild localizes it, so the diagnostic is matched on
/// the error code alone and the actionable explanation is supplied in the CLI's own language.
/// </remarks>
internal static string GetIntegrationBuildFailureMessage(OutputCollector buildOutput)
{
var hasPackageDowngrade = buildOutput.GetLines()
.Any(static l => l.Line.Contains("NU1605", StringComparison.Ordinal));
return hasPackageDowngrade
? string.Format(
CultureInfo.CurrentCulture,
ErrorStrings.IntegrationBuildPackageDowngradeFailed,
VersionHelper.GetDefaultTemplateVersion())
: ErrorStrings.IntegrationBuildFailed;
}
private async Task<(int ExitCode, OutputCollector Output)> BuildIntegrationProjectAsync(
string projectFilePath,
bool noRestore,
CancellationToken cancellationToken)
{
var buildOutput = new OutputCollector();
var exitCode = await _dotNetCliRunner.BuildAsync(
new FileInfo(projectFilePath),
noRestore,
new ProcessInvocationOptions
{
StandardOutputCallback = buildOutput.AppendOutput,
StandardErrorCallback = buildOutput.AppendError
},
cancellationToken).ConfigureAwait(false);
return (exitCode, buildOutput);
}
/// <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<AppHostServerClosureManifest> BuildIntegrationClosureManifestAsync(
List<IntegrationReference> packageRefs,
List<IntegrationReference> projectRefs,
List<IntegrationReference> npmIntegrations,
string? requestedChannel,
string? packageSourceOverride,
CancellationToken cancellationToken)
{
var restoreDir = Path.Combine(_workingDirectory, "integration-restore");
Directory.CreateDirectory(restoreDir);
// Only synthesize a temp NuGet.config (replacing nuget.config discovery via
// RestoreConfigFile) when an explicit --source or auto-discovered local channel source
// is in play. The explicit-channel-no-override path keeps the user's ambient
// nuget.config in place and contributes channel mappings additively via
// RestoreAdditionalProjectSources so private/internal feeds the user has configured
// remain reachable for non-Aspire transitives during project-ref restore.
using var temporaryNuGetConfig = !string.IsNullOrWhiteSpace(packageSourceOverride)
? await TryCreateTemporaryNuGetConfigAsync(requestedChannel, packageSourceOverride, cancellationToken)
: null;
var channelSources = temporaryNuGetConfig is null
? await GetNuGetSourcesAsync(requestedChannel, packageSourceOverride: null, cancellationToken)
: null;
var projectContent = GenerateIntegrationProjectFile(
packageRefs,
projectRefs,
restoreDir,
channelSources,
useExactPackageVersions: !string.IsNullOrWhiteSpace(packageSourceOverride),
restoreConfigFile: temporaryNuGetConfig?.ConfigFile.FullName);
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).ToString(),
cancellationToken);
// Write empty Directory.Build.targets to prevent parent targets imports.
await WriteIfChangedAsync(
Path.Combine(restoreDir, "Directory.Build.targets"), "<Project />", cancellationToken);
// Restore dominates this build - measured at 5.6s of a 6.7s warm build - and it only needs to
// run again when something restore actually reads has changed. That set of inputs is captured
// as a content fingerprint rather than a timestamp comparison, and the stamp recording it is
// written only after a restore succeeds.
//
// Skipping restore never skips the build itself, so an edit to a referenced project is still
// compiled. And because a stale or partially cleaned obj/ directory is the one thing the
// fingerprint cannot see, a no-restore build that fails on the assets file is retried with
// restore rather than reported.
var restoreInputs = await ComputeRestoreInputsAsync(projectContent, packageRefs, projectRefs, cancellationToken).ConfigureAwait(false);
var restoreFingerprint = restoreInputs.IsEligibleForSkip ? restoreInputs.Fingerprint : null;
var skipRestore = restoreFingerprint is not null && CanSkipIntegrationRestore(restoreDir, restoreFingerprint, _logger);
_logger.LogDebug("Building integration project with {PackageCount} packages and {ProjectCount} project references (restore {RestoreState})",
packageRefs.Count, projectRefs.Count, skipRestore ? "skipped" : "requested");
var (exitCode, buildOutput) = await BuildIntegrationProjectAsync(projectFilePath, noRestore: skipRestore, cancellationToken).ConfigureAwait(false);
if (exitCode != 0 && skipRestore && ShouldRetryWithRestore(buildOutput))
{
_logger.LogDebug("Integration project build failed on the restore assets; retrying with restore. First attempt output:\n{BuildOutput}",
string.Join(Environment.NewLine, buildOutput.GetLines().Select(l => l.Line)));
(exitCode, buildOutput) = await BuildIntegrationProjectAsync(projectFilePath, noRestore: false, cancellationToken).ConfigureAwait(false);
}
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 AppHostServerPrepareFailedException(GetIntegrationBuildFailureMessage(buildOutput), buildOutput);
}
if (restoreFingerprint is not null && !skipRestore)
{
await WriteRestoreStampAsync(restoreDir, restoreFingerprint, _logger, cancellationToken).ConfigureAwait(false);
}
var projectRefAssemblyNames = await IntegrationClosureBuilder.ReadProjectRefAssemblyNamesAsync(
restoreDir,
_logger,
cancellationToken).ConfigureAwait(false);
var appSettingsContent = CreateAppSettingsContent(packageRefs, projectRefAssemblyNames, npmIntegrations);
var closureManifest = await IntegrationClosureBuilder.ReadClosureManifestAsync(
restoreDir,
Path.Combine(restoreDir, "obj", 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 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 restoreDir,
IEnumerable<string>? additionalSources = null,
bool useExactPackageVersions = false,
string? restoreConfigFile = null)
{
IEnumerable<string>? restoreAdditionalSources = additionalSources;
if (!string.IsNullOrWhiteSpace(restoreConfigFile))
{
// RestoreAdditionalProjectSources can add feeds, but it cannot carry package source
// mappings. Use the generated NuGet.config so Aspire* packages stay pinned to the
// explicit source while non-Aspire dependencies can use fallback sources.
restoreAdditionalSources = null;
}
var projectFile = IntegrationClosureBuilder.CreateClosureProjectFile(
restoreDir,
restoreAdditionalSources,
restoreConfigFile);
foreach (var packageReference in packageRefs)
{
if (packageReference.Version is null)
{
throw new InvalidOperationException($"Package reference '{packageReference.Name}' is missing a version.");
}
projectFile.PackageReferences.Add(new CSharpPackageReference(
packageReference.Name,
GetRestoreVersion(packageReference.Name, packageReference.Version, useExactPackageVersions)));
}
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;
}
/// <summary>
/// Throws when the caller asked for the staging channel but the running CLI's packaging
/// service refuses to synthesize one (daily/local/pr-<c>N</c> identity without
/// <c>overrideStagingFeed</c> or the <c>StagingChannelEnabled</c> feature flag). Surfaces
/// the same actionable reason the <c>update</c> and <c>new</c> commands display so the
/// bundled AppHost restore path doesn't silently downgrade to the daily feed.
/// </summary>
private void ThrowIfStagingUnavailable(string? requestedChannel)
{
if (!string.Equals(requestedChannel, PackageChannelNames.Staging, StringComparisons.ChannelName))
{
return;
}
var reason = _packagingService.GetStagingChannelUnavailableReason();
if (reason is not null)
{
throw new InvalidOperationException(reason);
}
}
/// <summary>
/// Gets NuGet sources from the resolved channel for bundled restore.
/// </summary>
internal async Task<IEnumerable<string>?> GetNuGetSourcesAsync(string? requestedChannel, string? packageSourceOverride, CancellationToken cancellationToken)
{
// Refuse to silently downgrade staging restores to the shared daily feed when the running
// CLI cannot synthesize a real staging channel (daily/local/pr-<N>). PackagingService omits
// the staging channel in that case; without this check the lookup below falls through to
// "all explicit channels" — which on a daily CLI is the shared daily feed — and restore
// silently succeeds against the wrong feed. Surfacing the actionable
// GetStagingChannelUnavailableReason() mirrors UpdateCommand/NewCommand and closes the
// bundled-AppHost arm of https://github.com/microsoft/aspire/issues/16652.
ThrowIfStagingUnavailable(requestedChannel);
var sources = new List<string>();
if (!string.IsNullOrWhiteSpace(packageSourceOverride))
{
sources.Add(packageSourceOverride);
}
try
{
// When --source is set without a specific channel, do NOT fold in every explicit
// channel's sources: each built-in channel contributes its own Aspire* feed, and
// letting all of them through would give NuGet multiple co-eligible sources for
// Aspire packages and silently defeat the override. The temp NuGet.config below
// emits PSM that constrains Aspire packages to the override; this list only needs
// the override (plus a NuGet.org fallback) for non-Aspire transitives.
var channels = !string.IsNullOrWhiteSpace(packageSourceOverride) && string.IsNullOrEmpty(requestedChannel)
? []
: await GetExplicitRestoreChannelsAsync(requestedChannel, cancellationToken);
var hasOverride = !string.IsNullOrWhiteSpace(packageSourceOverride);
var matchedChannelHasAllPackagesMapping = false;
foreach (var channel in channels)
{
if (channel.Mappings is null)
{
continue;
}
foreach (var mapping in channel.Mappings)
{
// Stay consistent with TryCreateTemporaryNuGetConfigAsync, which drops the
// matched channel's Aspire* mapping in the override branch: the bundled
// restore tool treats `--source` CLI args as co-eligible with config
// mappings, so re-adding the channel's Aspire feed here would silently
// defeat the override even though the temp NuGet.config's PSM tries to
// pin Aspire* to the override exclusively.
if (hasOverride && mapping.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (mapping.PackageFilter == PackageMapping.AllPackages)
{
matchedChannelHasAllPackagesMapping = true;
}
if (!sources.Contains(mapping.Source, StringComparer.OrdinalIgnoreCase))
{
sources.Add(mapping.Source);
}
}
}
// Mirror the temp NuGet.config's catch-all decision: it adds `* -> NuGet.org`
// only when the matched channel did not supply its own AllPackages mapping. The
// --source argument list must agree so non-Aspire transitives have the same
// catch-all source in both views. Honor the runtime nuget service-index
// override here too — see docs/specs/cli-identity-sidecar.md.
var nugetOrg = _executionContext.NuGetServiceIndexOverride ?? PackageSources.NuGetOrg;
if (hasOverride && !matchedChannelHasAllPackagesMapping &&
!sources.Contains(nugetOrg, StringComparer.OrdinalIgnoreCase))
{
sources.Add(nugetOrg);
}
}
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;
}
internal async Task<TemporaryNuGetConfig?> TryCreateTemporaryNuGetConfigAsync(string? requestedChannel, string? packageSourceOverride, CancellationToken cancellationToken)
{
// Keep staging refusal consistent across both temp-config branches. The project-reference
// restore path skips GetNuGetSourcesAsync when a temp config exists, so this method must
// surface the actionable staging-unavailable reason before building any override config.
ThrowIfStagingUnavailable(requestedChannel);
if (!string.IsNullOrWhiteSpace(packageSourceOverride))
{
// Treat an explicit --source value as the preferred source for Aspire packages.
// Build a temporary NuGet.config that routes Aspire* there, optionally preserves
// non-Aspire channel mappings, and leaves a fallback source for non-Aspire deps.
PackageChannel? matchedChannel = null;
var configureGlobalPackagesFolder = false;
try
{
// Only fold in mappings from an explicitly-requested, matched channel. Falling
// back to "all explicit channels" here would pull in every built-in channel's
// Aspire* mapping pointing at its own feed; NuGet would treat all of them as
// co-eligible sources for Aspire packages and silently defeat the override.
if (!string.IsNullOrEmpty(requestedChannel))
{
var packageChannels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel);
matchedChannel = packageChannels.FirstOrDefault(c =>
string.Equals(c.Name, requestedChannel, StringComparisons.ChannelName));
if (matchedChannel is not null)
{
configureGlobalPackagesFolder |= matchedChannel.ConfigureGlobalPackagesFolder;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get package channels while creating source override NuGet.config");
}
return await TemporaryNuGetConfig.CreateAsync(
PackageSourceOverrideMappings.Create(packageSourceOverride, matchedChannel, _executionContext.NuGetServiceIndexOverride),
configureGlobalPackagesFolder,
configureGlobalPackagesFolder ? ResolveStableGlobalPackagesFolder(packageSourceOverride) : null);
}
if (string.IsNullOrEmpty(requestedChannel))
{
return null;
}
PackageChannel? channel;
try
{
var channels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel);
channel = channels.FirstOrDefault(c =>
c.Type == PackageChannelType.Explicit &&
c.Mappings is { Length: > 0 } &&
string.Equals(c.Name, requestedChannel, StringComparisons.ChannelName));
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// Mirror the defensive catch in the override branch above and in
// ResolveLocalPackageSourceOverrideAsync / GetNuGetSourcesAsync: a transient
// packaging-service failure must degrade to the ambient nuget.config + the
// caller's separately resolved channel-source list, rather than failing the
// whole PrepareAsync. Returning null skips the PSM-bearing temp config; for
// non-staging channels the caller still gets channel sources via
// GetNuGetSourcesAsync (which catches), and for staging the unavailable-reason
// refusal above has already short-circuited before we reach this point.
_logger.LogWarning(ex, "Failed to get package channels while creating channel NuGet.config for '{Channel}'.", requestedChannel);
return null;
}
if (channel?.Mappings is null)
{
return null;
}
// Skip PSM only when the resolved channel is the local hive — that hive is a transient
// dev-build artifact with no real package mappings, so emitting PSM for it would just
// constrain restore to an empty source set. For every other channel (stable, staging,
// daily, pr-*) PSM must emit so restore honours the channel's package source mappings —
// regardless of which CLI identity (CliExecutionContext.IdentityChannel) is running.
// Keying on the resolved channel.Name (rather than the input requestedChannel) is robust
// to alias/normalization in the channel lookup above.
if (string.Equals(channel.Name, PackageChannelNames.Local, StringComparisons.ChannelName))
{
return null;
}
// Materializing the temp config is required for explicit channels so that
// restore honors the channel's package source mappings. Let IO/XML failures
// surface instead of silently falling back to the caller's unmapped sources,
// which could otherwise restore from an unintended feed.
return await TemporaryNuGetConfig.CreateAsync(
channel.Mappings,
channel.ConfigureGlobalPackagesFolder,
channel.ConfigureGlobalPackagesFolder ? ResolveStableGlobalPackagesFolder(GetPrimaryFeedUrl(channel.Mappings)) : null);
}
/// <summary>
/// Returns the absolute <c>globalPackagesFolder</c> path to write into a temporary NuGet.config
/// when the resolved channel asks for per-build cache isolation (today: <c>staging</c>).
/// </summary>
/// <remarks>
/// The default <see cref="NuGetConfigMerger.DefaultGlobalPackagesFolderValue"/> is a relative
/// <c>.nugetpackages</c> path that NuGet resolves next to the nuget.config it came from. For
/// the <see cref="NuGetConfigMerger"/> workspace-merge flow that's fine — the merged config is
/// persistent. For <see cref="PrebuiltAppHostServer"/>'s <see cref="TemporaryNuGetConfig"/>
/// the config file lives in a Directory.CreateTempSubdirectory("aspire-nuget-config") folder
/// that <see cref="TemporaryNuGetConfig.Dispose"/> recursively deletes after restore. NuGet
/// would have just populated <c><temp>/.nugetpackages/<id>/<version>/</c>
/// with the staging assemblies, <see cref="NuGet.BundleNuGetService"/> would have baked those
/// paths into <c>integration-package-probe-manifest.json</c>, and aspire-managed would then
/// try to load assemblies the dispose just removed — observed as a hang during DI / assembly
/// loading on macOS osx-arm64 polyglot staging builds. Anchoring the override at a stable
/// per-build location keeps the cached packages alive for as long as any manifest references
/// them.
///
/// The cache lives under <see cref="CliExecutionContext.AspireHomeDirectory"/> (i.e. the
/// <c>ASPIRE_HOME</c> override when set, otherwise <c>~/.aspire</c>) rather than under
/// <see cref="_workingDirectory"/> so that two AppHosts running on the same machine against
/// the same staging build can share a single restore — the unit of cache isolation here is
/// the staging build, not the individual restore command.
///
/// The cache subdirectory is keyed by a truncated hash of the resolved feed URL (first 8
/// hex chars of <see cref="System.IO.Hashing.XxHash3"/> over the trimmed/lower-cased URL).
/// Two staging builds of the same release branch — which share the same stable-shaped semver
/// (e.g. <c>13.4.0</c>) but ship from different darc feeds — therefore each get their own
/// cache. A user pointing the same CLI at multiple <c>overrideStagingFeed</c> values during
/// dev/test also gets a distinct cache per feed, instead of one bucket silently shared across
/// feeds. NuGet identifies packages by <c>(id, version)</c> only, so without that per-feed
/// key the second feed's restore would silently reuse the first feed's now-stale
/// <c>13.4.0</c> assemblies. When <paramref name="feedUrl"/> is null or empty (defensive —
/// both call sites currently always pass a real URL) the key falls back to <c>"default"</c>
/// so the path is still well-formed.
/// </remarks>
private string ResolveStableGlobalPackagesFolder(string? feedUrl)
{
var cacheKey = CliPathHelper.ComputeStagingFeedCacheKey(feedUrl) ?? "default";
return Path.Combine(
CliPathHelper.GetStagingNuGetPackagesDirectory(_executionContext.AspireHomeDirectory),
cacheKey);
}
/// <summary>
/// Returns the URL we use as the cache-key input when materializing a temp nuget.config from
/// a <see cref="PackageChannel"/>. Prefers the explicit <c>Aspire*</c> mapping (the staging
/// channel's primary feed and the one whose restored assemblies actually need cache
/// isolation), falling back to the first mapping for forward compatibility with channel
/// shapes we don't yet emit.
/// </summary>
private static string GetPrimaryFeedUrl(PackageMapping[] mappings)
{
var aspire = mappings.FirstOrDefault(m =>
string.Equals(m.PackageFilter, "Aspire*", StringComparison.OrdinalIgnoreCase));
return aspire?.Source ?? mappings[0].Source;
}
private async Task<string?> ResolveLocalPackageSourceOverrideAsync(string? requestedChannel, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(requestedChannel))
{
return null;
}
PackageChannel? channel;
try
{
var channels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel);
channel = channels.FirstOrDefault(c =>
c.Type == PackageChannelType.Explicit &&
c.Mappings is { Length: > 0 } &&
string.Equals(c.Name, requestedChannel, StringComparisons.ChannelName));
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// A transient packaging-service failure during auto-discovery must not turn
// `aspire new` into a hard failure. Returning null falls through to the existing
// ambient + channel-sources path, matching the defensive catches in
// TryCreateTemporaryNuGetConfigAsync and GetNuGetSourcesAsync.
_logger.LogWarning(ex, "Failed to resolve local Aspire package source for channel '{Channel}'.", requestedChannel);
return null;
}
var source = channel is null ? null : GetExistingLocalAspirePackageSource(channel);
if (!string.IsNullOrWhiteSpace(source))
{
_logger.LogDebug("Using local package source '{Source}' for channel '{Channel}'.", source, requestedChannel);
}
return source;
}
private static string? GetExistingLocalAspirePackageSource(PackageChannel channel)
{
if (channel.Mappings is null)
{
return null;
}
foreach (var mapping in channel.Mappings)
{
if (!IsAspireSpecificMapping(mapping) ||
PackageSourceOverrideMappings.GetNormalizedLocalDirectory(mapping.Source) is not { } localDirectory ||
!Directory.Exists(localDirectory))
{
continue;
}
return mapping.Source;
}
return null;
}
private static bool IsAspireSpecificMapping(PackageMapping mapping) =>
mapping.PackageFilter != PackageMapping.AllPackages &&
mapping.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase);
private async Task<IEnumerable<PackageChannel>> GetExplicitRestoreChannelsAsync(string? requestedChannel, CancellationToken cancellationToken)
{
var channels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel);
if (!string.IsNullOrEmpty(requestedChannel))
{
var matchingChannel = channels.FirstOrDefault(c => string.Equals(c.Name, requestedChannel, StringComparisons.ChannelName));
if (matchingChannel is not null)
{
return [matchingChannel];
}
}
return channels.Where(c => c.Type == PackageChannelType.Explicit).ToArray();
}
private static string GetRestoreVersion(string packageName, string version, bool useExactPackageVersions)
{
var shouldUseExactAspirePackageVersion = useExactPackageVersions && packageName.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase);
if (!shouldUseExactAspirePackageVersion || version.Length == 0 || version[0] is '[' or '(')
{
return version;
}
return $"[{version}]";
}
// 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;
}
}