// 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.CodeAnalysis;
using System.Globalization;
using System.Reflection;
using Aspire.Shared;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
namespace Aspire.Hosting.Dcp;
internal sealed class DcpOptions
{
/// <summary>
/// The path to the DCP executable used for Aspire orchestration
/// </summary>
/// <example>
/// C:\Program Files\dotnet\packs\Aspire.Hosting.Orchestration.win-x64\8.0.0-preview.1.23518.6\tools\dcp.exe
/// </example>
public string? CliPath { get; set; }
/// <summary>
/// Optional path to a folder containing the DCP extension assemblies.
/// </summary>
/// <example>
/// C:\Program Files\dotnet\packs\Aspire.Hosting.Orchestration.win-x64\8.0.0-preview.1.23518.6\tools\ext\
/// </example>
public string? ExtensionsPath { get; set; }
/// <summary>
/// Optional path to a folder containing the Aspire Dashboard binaries.
/// </summary>
/// <example>
/// When running the playground applications in this repo: <c>..\..\..\artifacts\bin\Aspire.Dashboard\Debug\net8.0\Aspire.Dashboard.dll</c>
/// </example>
public string? DashboardPath { get; set; }
/// <summary>
/// Optional path to the Aspire Terminal Host binary.
/// </summary>
public string? TerminalHostPath { get; set; }
/// <summary>
/// Optional invocation args that must be prepended when launching <see cref="TerminalHostPath"/>.
/// In the CLI bundle case the path is the multi-mode <c>aspire-managed</c> exe and this is set
/// to <c>"terminalhost"</c> so the dispatcher routes to <c>TerminalHostApp.RunAsync</c>.
/// Empty for the standalone per-RID NuGet package and inner-loop cases.
/// </summary>
public string? TerminalHostInvocationArgs { get; set; }
/// <summary>
/// Optional container runtime to override default runtime for DCP containers.
/// </summary>
/// <example>
/// podman
/// </example>
public string? ContainerRuntime { get; set; }
/// <summary>
/// How long the dependency check will wait (in seconds) for a response before timing out.
/// Timeout is disabled if set to zero or a negative value.
/// </summary>
public int DependencyCheckTimeout { get; set; } = 25;
/// <summary>
/// The suffix to use for resource names when creating resources in DCP.
/// </summary>
public string? ResourceNameSuffix { get; set; }
/// <summary>
/// Whether to randomize ports used by resources during orchestration.
/// </summary>
public bool RandomizePorts { get; set; }
/// <summary>
/// The first port in the range used to allocate unspecified public ports for proxyless endpoints.
/// </summary>
public int ProxylessEndpointPortRangeStart { get; set; } = 10000;
/// <summary>
/// The last port in the range used to allocate unspecified public ports for proxyless endpoints.
/// </summary>
/// <remarks>
/// The default leaves room for Aspire to persist stable allocated ports in the future while staying
/// compatible across supported OSes. Linux's default ephemeral range starts at 32768, which is the
/// most restrictive default among those OSes, so default allocations stop one port lower.
/// </remarks>
public int ProxylessEndpointPortRangeEnd { get; set; } = 32767;
public int KubernetesConfigReadRetryCount { get; set; } = 300;
public int KubernetesConfigReadRetryIntervalMilliseconds { get; set; } = 100;
/// <summary>
/// The duration to wait for the container runtime to become healthy before aborting startup.
/// </summary>
/// <remarks>
/// A value of zero, which is the default value, indicates that the application will not wait for the container
/// runtime to become healthy.
/// If this property has a value greater than zero, the application will abort startup if the container runtime
/// does not become healthy within the specified timeout.
/// </remarks>
public TimeSpan ContainerRuntimeInitializationTimeout { get; set; }
public TimeSpan ServiceStartupWatchTimeout { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Whether to wait for resource cleanup to end when stopping DcpExecutor.
/// This guarantees that application resources (programs, transient containers etc.) are stopped
/// before DcpExecutor.StopAsync() returns. Default is false (resources are cleaned up asynchronously).
/// </summary>
public bool WaitForResourceCleanup { get; set; }
/// <summary>
/// Gets or sets the suffix to use for DCP log file names (applicable when verbose DCP logging is enabled).
/// By default log file name suffix defaults to the current process ID.
/// </summary>
public string? LogFileNameSuffix { get; set; }
/// <summary>
/// Gets or sets the folder path where DCP diagnostics logs are written.
/// If set, overrides the DCP_DIAGNOSTICS_LOG_FOLDER environment variable.
/// </summary>
public string? DiagnosticsLogFolder { get; set; }
/// <summary>
/// Gets or sets the DCP diagnostics log level.
/// If set, overrides the DCP_DIAGNOSTICS_LOG_LEVEL environment variable.
/// </summary>
public string? DiagnosticsLogLevel { get; set; }
/// <summary>
/// Gets or sets whether DCP should preserve executable logs.
/// If set to true, overrides the DCP_PRESERVE_EXECUTABLE_LOGS environment variable.
/// </summary>
public bool? PreserveExecutableLogs { get; set; }
/// <summary>
/// Enables Aspire container tunnel for container-to-host connectivity across all container orchestrators.
/// </summary>
public bool EnableAspireContainerTunnel { get; set; } = true;
}
internal class ValidateDcpOptions(DistributedApplicationExecutionContext executionContext) : IValidateOptions<DcpOptions>
{
public ValidateOptionsResult Validate(string? name, DcpOptions options)
{
var builder = new ValidateOptionsResultBuilder();
if (executionContext.IsRunMode && string.IsNullOrWhiteSpace(options.CliPath))
{
builder.AddError("The path to the DCP executable used for Aspire orchestration is required.", "CliPath");
}
if (executionContext.IsRunMode && string.IsNullOrWhiteSpace(options.DashboardPath))
{
builder.AddError("The path to the Aspire Dashboard binaries is missing.", "DashboardPath");
}
if (!PortRange.IsValidPort(options.ProxylessEndpointPortRangeStart))
{
builder.AddError($"The proxyless endpoint port range start must be between {PortRange.MinPort} and {PortRange.MaxPort}.", nameof(options.ProxylessEndpointPortRangeStart));
}
if (!PortRange.IsValidPort(options.ProxylessEndpointPortRangeEnd))
{
builder.AddError($"The proxyless endpoint port range end must be between {PortRange.MinPort} and {PortRange.MaxPort}.", nameof(options.ProxylessEndpointPortRangeEnd));
}
if (options.ProxylessEndpointPortRangeStart > options.ProxylessEndpointPortRangeEnd)
{
builder.AddError("The proxyless endpoint port range start must be less than or equal to the range end.", nameof(options.ProxylessEndpointPortRangeStart));
}
return builder.Build();
}
}
internal class ConfigureDefaultDcpOptions(
DistributedApplicationOptions appOptions,
IConfiguration configuration) : IConfigureOptions<DcpOptions>
{
private const string DcpCliPathMetadataKey = "DcpCliPath";
private const string DcpExtensionsPathMetadataKey = "DcpExtensionsPath";
private const string DashboardPathMetadataKey = "aspiredashboardpath";
private const string TerminalHostPathMetadataKey = "aspireterminalhostpath";
private const string TerminalHostInvocationArgsMetadataKey = "aspireterminalhostinvocationargs";
public static string DcpPublisher = nameof(DcpPublisher);
public void Configure(DcpOptions options)
{
var dcpPublisherConfiguration = configuration.GetSection(DcpPublisher);
var assemblyMetadata = appOptions.Assembly?.GetCustomAttributes<AssemblyMetadataAttribute>();
// Priority 1: Check explicit DcpPublisher configuration first (env vars are automatically bound via IConfiguration)
// Priority 2: BundleDiscovery env vars: ASPIRE_DCP_PATH, ASPIRE_DASHBOARD_PATH
var configDcpPath = configuration[BundleDiscovery.DcpPathEnvVar];
var configDashboardPath = configuration[BundleDiscovery.DashboardPathEnvVar];
if (!string.IsNullOrWhiteSpace(dcpPublisherConfiguration[nameof(options.CliPath)]))
{
// If an explicit path to DCP was provided from configuration
options.CliPath = dcpPublisherConfiguration[nameof(options.CliPath)];
if (Path.GetDirectoryName(options.CliPath) is string dcpDir && !string.IsNullOrEmpty(dcpDir))
{
options.ExtensionsPath = Path.Combine(dcpDir, "ext");
}
}
else if (!string.IsNullOrWhiteSpace(configDcpPath))
{
// Configuration/environment variable override - set DCP paths from bundle
options.CliPath = BundleDiscovery.GetDcpExecutablePath(configDcpPath);
options.ExtensionsPath = Path.Combine(configDcpPath, "ext");
}
else
{
// Resolve via assembly metadata attributes (NuGet packages)
options.CliPath = GetMetadataValue(assemblyMetadata, DcpCliPathMetadataKey);
options.ExtensionsPath = GetMetadataValue(assemblyMetadata, DcpExtensionsPathMetadataKey);
}
if (!string.IsNullOrWhiteSpace(dcpPublisherConfiguration[nameof(options.DashboardPath)]))
{
// If an explicit path to Dashboard was provided from configuration
options.DashboardPath = dcpPublisherConfiguration[nameof(options.DashboardPath)];
}
else if (!string.IsNullOrWhiteSpace(configDashboardPath))
{
// Configuration/environment variable override - set Dashboard path from bundle
options.DashboardPath = configDashboardPath;
}
else
{
// Resolve via assembly metadata attributes (NuGet packages)
options.DashboardPath = GetMetadataValue(assemblyMetadata, DashboardPathMetadataKey);
}
// Terminal Host path resolution (same pattern as Dashboard)
var configTerminalHostPath = configuration[BundleDiscovery.TerminalHostPathEnvVar];
if (!string.IsNullOrEmpty(configTerminalHostPath))
{
options.TerminalHostPath = configTerminalHostPath;
}
else if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(options.TerminalHostPath)]))
{
options.TerminalHostPath = dcpPublisherConfiguration[nameof(options.TerminalHostPath)];
}
else
{
options.TerminalHostPath = GetMetadataValue(assemblyMetadata, TerminalHostPathMetadataKey);
}
// Terminal Host invocation args (used when the binary is the multi-mode aspire-managed exe in the bundle).
var configTerminalHostInvocationArgs = configuration[BundleDiscovery.TerminalHostInvocationArgsEnvVar];
if (!string.IsNullOrEmpty(configTerminalHostInvocationArgs))
{
options.TerminalHostInvocationArgs = configTerminalHostInvocationArgs;
}
else if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(options.TerminalHostInvocationArgs)]))
{
options.TerminalHostInvocationArgs = dcpPublisherConfiguration[nameof(options.TerminalHostInvocationArgs)];
}
else
{
options.TerminalHostInvocationArgs = GetMetadataValue(assemblyMetadata, TerminalHostInvocationArgsMetadataKey);
}
// Discovery order for the terminal host binary:
//
// 1. ASPIRE_TERMINAL_HOST_PATH environment variable (manual override, e.g.
// side-loading a custom build for development).
// 2. dcpPublisherConfiguration[TerminalHostPath] (programmatic override, e.g.
// tests or in-proc hosts.)
// 3. Assembly metadata "aspireterminalhostpath" baked into the AppHost at
// build time by ResolveAspireCliBundle (the SetTerminalHostDiscoveryAttributes
// MSBuild target). This is the **primary** path in the normal `dotnet build`
// → `dotnet run` case — by the time the AppHost runs, the metadata already
// points at the bundled aspire-managed binary.
// 4. Runtime inference from DashboardPath (below): only fires when none of the
// above produced a value, which happens when the AppHost was built on a
// machine where ResolveAspireCliBundle could not locate the bundle, but at
// runtime the launching CLI did set ASPIRE_DASHBOARD_PATH. Since 13.4 the
// bundle ships a single multi-mode aspire-managed exe that dispatches to
// dashboard / terminalhost via a leading subcommand arg, so reusing
// DashboardPath as the terminal host (with "terminalhost" as the dispatch
// arg) is correct.
//
// Note: if both the dashboard and terminal host paths end up empty, .WithTerminal()
// resources will fail at start time; see TerminalHostFailureDiagnosticService for
// the user-facing recovery (unhide the failed host, inject an actionable log line).
if (string.IsNullOrEmpty(options.TerminalHostPath) &&
!string.IsNullOrEmpty(options.DashboardPath) &&
BundleDiscovery.IsAspireManagedBinary(options.DashboardPath))
{
options.TerminalHostPath = options.DashboardPath;
if (string.IsNullOrEmpty(options.TerminalHostInvocationArgs))
{
options.TerminalHostInvocationArgs = "terminalhost";
}
}
if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(options.ContainerRuntime)]))
{
options.ContainerRuntime = dcpPublisherConfiguration[nameof(options.ContainerRuntime)];
}
else
{
options.ContainerRuntime = configuration.GetString(KnownConfigNames.ContainerRuntime, KnownConfigNames.Legacy.ContainerRuntime);
}
if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(options.DependencyCheckTimeout)]))
{
if (int.TryParse(dcpPublisherConfiguration[nameof(options.DependencyCheckTimeout)], out var timeout))
{
options.DependencyCheckTimeout = timeout;
}
else
{
throw new InvalidOperationException($"Invalid value \"{dcpPublisherConfiguration[nameof(options.DependencyCheckTimeout)]}\" for \"--dcp-dependency-check-timeout\". Expected an integer value.");
}
}
else
{
options.DependencyCheckTimeout = configuration.GetValue(KnownConfigNames.DependencyCheckTimeout, KnownConfigNames.Legacy.DependencyCheckTimeout, options.DependencyCheckTimeout);
}
options.KubernetesConfigReadRetryCount = dcpPublisherConfiguration.GetValue(nameof(options.KubernetesConfigReadRetryCount), options.KubernetesConfigReadRetryCount);
options.KubernetesConfigReadRetryIntervalMilliseconds = dcpPublisherConfiguration.GetValue(nameof(options.KubernetesConfigReadRetryIntervalMilliseconds), options.KubernetesConfigReadRetryIntervalMilliseconds);
if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(options.ResourceNameSuffix)]))
{
options.ResourceNameSuffix = dcpPublisherConfiguration[nameof(options.ResourceNameSuffix)];
}
options.RandomizePorts = dcpPublisherConfiguration.GetValue(nameof(options.RandomizePorts), options.RandomizePorts);
options.ProxylessEndpointPortRangeStart = dcpPublisherConfiguration.GetValue(nameof(options.ProxylessEndpointPortRangeStart), options.ProxylessEndpointPortRangeStart);
options.ProxylessEndpointPortRangeEnd = dcpPublisherConfiguration.GetValue(nameof(options.ProxylessEndpointPortRangeEnd), options.ProxylessEndpointPortRangeEnd);
ApplyProxylessEndpointPortRangeOverride(options, configuration);
options.WaitForResourceCleanup = dcpPublisherConfiguration.GetValue(nameof(options.WaitForResourceCleanup), options.WaitForResourceCleanup);
options.ServiceStartupWatchTimeout = configuration.GetValue(KnownConfigNames.ServiceStartupWatchTimeout, KnownConfigNames.Legacy.ServiceStartupWatchTimeout, options.ServiceStartupWatchTimeout);
options.ContainerRuntimeInitializationTimeout = dcpPublisherConfiguration.GetValue(nameof(options.ContainerRuntimeInitializationTimeout), options.ContainerRuntimeInitializationTimeout);
options.LogFileNameSuffix = dcpPublisherConfiguration[nameof(options.LogFileNameSuffix)];
options.DiagnosticsLogFolder = dcpPublisherConfiguration[nameof(options.DiagnosticsLogFolder)];
options.DiagnosticsLogLevel = dcpPublisherConfiguration[nameof(options.DiagnosticsLogLevel)];
options.PreserveExecutableLogs = dcpPublisherConfiguration.GetValue<bool?>(nameof(options.PreserveExecutableLogs), options.PreserveExecutableLogs);
options.EnableAspireContainerTunnel = configuration.GetValue(KnownConfigNames.EnableContainerTunnel, options.EnableAspireContainerTunnel);
}
private static void ApplyProxylessEndpointPortRangeOverride(DcpOptions options, IConfiguration configuration)
{
if (configuration[KnownConfigNames.ProxylessEndpointPortRange] is not { Length: > 0 } configuredRange)
{
return;
}
var separatorIndex = configuredRange.IndexOf('-', StringComparison.Ordinal);
if (separatorIndex < 0 || separatorIndex != configuredRange.LastIndexOf('-'))
{
ThrowInvalidProxylessEndpointPortRange(configuredRange);
}
var startText = configuredRange[..separatorIndex].Trim();
var endText = configuredRange[(separatorIndex + 1)..].Trim();
if (!int.TryParse(startText, NumberStyles.None, CultureInfo.InvariantCulture, out var start))
{
ThrowInvalidProxylessEndpointPortRange(configuredRange);
}
if (!int.TryParse(endText, NumberStyles.None, CultureInfo.InvariantCulture, out var end))
{
ThrowInvalidProxylessEndpointPortRange(configuredRange);
}
options.ProxylessEndpointPortRangeStart = start;
options.ProxylessEndpointPortRangeEnd = end;
}
[DoesNotReturn]
private static void ThrowInvalidProxylessEndpointPortRange(string configuredRange)
{
throw new InvalidOperationException(
$"Invalid value \"{configuredRange}\" for \"{KnownConfigNames.ProxylessEndpointPortRange}\". Expected a port range formatted as \"start-end\", for example \"10000-32767\".");
}
private static string? GetMetadataValue(IEnumerable<AssemblyMetadataAttribute>? assemblyMetadata, string key)
{
return assemblyMetadata?.FirstOrDefault(m => string.Equals(m.Key, key, StringComparison.OrdinalIgnoreCase))?.Value;
}
}