diff --git a/src/Aspire.Hosting.Dapr/IDistributedApplicationBuilderExtensions.cs b/src/Aspire.Hosting.Dapr/IDistributedApplicationBuilderExtensions.cs index 7ee47ca3da..889173b146 100644 --- a/src/Aspire.Hosting.Dapr/IDistributedApplicationBuilderExtensions.cs +++ b/src/Aspire.Hosting.Dapr/IDistributedApplicationBuilderExtensions.cs @@ -50,7 +50,7 @@ public static IResourceBuilder AddDaprComponent(this IDi { Properties = [], ResourceType = "DaprComponent", - State = "Hidden" + State = KnownResourceStates.Hidden }) .WithAnnotation(new ManifestPublishingCallbackAnnotation(context => WriteDaprComponentResourceToManifest(context, resource))); } diff --git a/src/Aspire.Hosting.Dapr/IDistributedApplicationComponentBuilderExtensions.cs b/src/Aspire.Hosting.Dapr/IDistributedApplicationComponentBuilderExtensions.cs index 0426de300e..de4f6508cb 100644 --- a/src/Aspire.Hosting.Dapr/IDistributedApplicationComponentBuilderExtensions.cs +++ b/src/Aspire.Hosting.Dapr/IDistributedApplicationComponentBuilderExtensions.cs @@ -59,7 +59,7 @@ public static IResourceBuilder WithDaprSidecar(this IResourceBuilder bu { Properties = [], ResourceType = "DaprSidecar", - State = "Hidden" + State = KnownResourceStates.Hidden }); configureSidecar(sidecarBuilder); diff --git a/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs index dd8d77db33..7a761ba0b9 100644 --- a/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs +++ b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs @@ -110,3 +110,14 @@ public static class KnownResourceStateStyles public static readonly string Warn = "warn"; } + +/// +/// The set of well known resource states +/// +public static class KnownResourceStates +{ + /// + /// The hidden state. Useful for hiding the resource. + /// + public static readonly string Hidden = "Hidden"; +} diff --git a/src/Aspire.Hosting/Dashboard/DashboardLifecycleHook.cs b/src/Aspire.Hosting/Dashboard/DashboardLifecycleHook.cs new file mode 100644 index 0000000000..3ee3952afb --- /dev/null +++ b/src/Aspire.Hosting/Dashboard/DashboardLifecycleHook.cs @@ -0,0 +1,157 @@ +// 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 Aspire.Dashboard.Model; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Dcp; +using Aspire.Hosting.Lifecycle; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Aspire.Hosting.Dashboard; + +internal sealed class DashboardLifecycleHook(IConfiguration configuration, + IOptions dashboardOptions, + ILogger distributedApplicationLogger, + IDashboardEndpointProvider dashboardEndpointProvider, + DistributedApplicationExecutionContext executionContext) : IDistributedApplicationLifecycleHook +{ + public Task BeforeStartAsync(DistributedApplicationModel model, CancellationToken cancellationToken) + { + Debug.Assert(executionContext.IsRunMode, "Dashboard resource should only be added in run mode"); + + if (model.Resources.SingleOrDefault(r => StringComparers.ResourceName.Equals(r.Name, KnownResourceNames.AspireDashboard)) is { } dashboardResource) + { + ConfigureAspireDashboardResource(dashboardResource); + + // Make the dashboard first in the list so it starts as fast as possible. + model.Resources.Remove(dashboardResource); + model.Resources.Insert(0, dashboardResource); + } + else + { + AddDashboardResource(model); + } + + return Task.CompletedTask; + } + + private void AddDashboardResource(DistributedApplicationModel model) + { + if (dashboardOptions.Value.DashboardPath is not { } dashboardPath) + { + throw new DistributedApplicationException("Dashboard path empty or file does not exist."); + } + + var fullyQualifiedDashboardPath = Path.GetFullPath(dashboardPath); + var dashboardWorkingDirectory = Path.GetDirectoryName(fullyQualifiedDashboardPath); + + ExecutableResource? dashboardResource = default; + + if (string.Equals(".dll", Path.GetExtension(fullyQualifiedDashboardPath), StringComparison.OrdinalIgnoreCase)) + { + // The dashboard path is a DLL, so run it with `dotnet ` + dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, "dotnet", dashboardWorkingDirectory ?? ""); + + dashboardResource.Annotations.Add(new CommandLineArgsCallbackAnnotation(args => + { + args.Add(fullyQualifiedDashboardPath); + })); + } + else + { + // Assume the dashboard path is directly executable + dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, fullyQualifiedDashboardPath, dashboardWorkingDirectory ?? ""); + } + + ConfigureAspireDashboardResource(dashboardResource); + + // Make the dashboard first in the list so it starts as fast as possible. + model.Resources.Insert(0, dashboardResource); + } + + private void ConfigureAspireDashboardResource(IResource dashboardResource) + { + // Remove endpoint annotations because we are directly configuring + // the dashboard app (it doesn't go through the proxy!). + var endpointAnnotations = dashboardResource.Annotations.OfType().ToList(); + foreach (var endpointAnnotation in endpointAnnotations) + { + dashboardResource.Annotations.Remove(endpointAnnotation); + } + + var snapshot = new CustomResourceSnapshot() + { + Properties = [], + ResourceType = dashboardResource switch + { + ExecutableResource => KnownResourceTypes.Executable, + ProjectResource => KnownResourceTypes.Project, + _ => KnownResourceTypes.Container + }, + State = configuration.GetBool("DOTNET_ASPIRE_SHOW_DASHBOARD_RESOURCES") is true ? null : KnownResourceStates.Hidden + }; + + dashboardResource.Annotations.Add(new ResourceSnapshotAnnotation(snapshot)); + + dashboardResource.Annotations.Add(new EnvironmentCallbackAnnotation(async context => + { + var options = dashboardOptions.Value; + + // Options should have been validated these should not be null + + Debug.Assert(options.DashboardUrl is not null, "DashboardUrl should not be null"); + Debug.Assert(options.OtlpEndpointUrl is not null, "OtlpEndpointUrl should not be null"); + + var dashboardUrls = options.DashboardUrl; + var otlpEndpointUrl = options.OtlpEndpointUrl; + + var environment = options.AspNetCoreEnvironment; + var browserToken = options.DashboardToken; + var otlpApiKey = options.OtlpApiKey; + + var resourceServiceUrl = await dashboardEndpointProvider.GetResourceServiceUriAsync(context.CancellationToken).ConfigureAwait(false); + + context.EnvironmentVariables["ASPNETCORE_ENVIRONMENT"] = environment; + context.EnvironmentVariables[DashboardConfigNames.DashboardFrontendUrlName.EnvVarName] = dashboardUrls; + context.EnvironmentVariables[DashboardConfigNames.ResourceServiceUrlName.EnvVarName] = resourceServiceUrl; + context.EnvironmentVariables[DashboardConfigNames.DashboardOtlpUrlName.EnvVarName] = otlpEndpointUrl; + context.EnvironmentVariables[DashboardConfigNames.ResourceServiceAuthModeName.EnvVarName] = "Unsecured"; + + if (!string.IsNullOrEmpty(browserToken)) + { + context.EnvironmentVariables[DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName] = "BrowserToken"; + context.EnvironmentVariables[DashboardConfigNames.DashboardFrontendBrowserTokenName.EnvVarName] = browserToken; + } + else + { + context.EnvironmentVariables[DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName] = "Unsecured"; + } + + if (!string.IsNullOrEmpty(otlpApiKey)) + { + context.EnvironmentVariables[DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName] = "ApiKey"; + context.EnvironmentVariables[DashboardConfigNames.DashboardOtlpPrimaryApiKeyName.EnvVarName] = otlpApiKey; + } + else + { + context.EnvironmentVariables[DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName] = "Unsecured"; + } + + // We need to print out the url so that dotnet watch can launch the dashboard + // technically this is too early, but it's late ne + if (StringUtils.TryGetUriFromDelimitedString(dashboardUrls, ";", out var firstDashboardUrl)) + { + distributedApplicationLogger.LogInformation("Now listening on: {DashboardUrl}", firstDashboardUrl.ToString().TrimEnd('/')); + } + + if (!string.IsNullOrEmpty(browserToken)) + { + LoggingHelpers.WriteDashboardUrl(distributedApplicationLogger, dashboardUrls, browserToken); + } + })); + } +} diff --git a/src/Aspire.Hosting/Dashboard/DashboardManifestExclusionHook.cs b/src/Aspire.Hosting/Dashboard/DashboardManifestExclusionHook.cs deleted file mode 100644 index 33e3a3c3be..0000000000 --- a/src/Aspire.Hosting/Dashboard/DashboardManifestExclusionHook.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Lifecycle; - -namespace Aspire.Hosting.Dashboard; -internal sealed class DashboardManifestExclusionHook : IDistributedApplicationLifecycleHook -{ - public Task BeforeStartAsync(DistributedApplicationModel model, CancellationToken cancellationToken) - { - if (model.Resources.SingleOrDefault(r => StringComparers.ResourceName.Equals(r.Name, KnownResourceNames.AspireDashboard)) is { } dashboardResource) - { - dashboardResource.Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore); - } - - return Task.CompletedTask; - } -} diff --git a/src/Aspire.Hosting/Dashboard/DashboardOptions.cs b/src/Aspire.Hosting/Dashboard/DashboardOptions.cs new file mode 100644 index 0000000000..12067c11fc --- /dev/null +++ b/src/Aspire.Hosting/Dashboard/DashboardOptions.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Dcp; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Aspire.Hosting.Dashboard; + +internal class DashboardOptions +{ + public string? DashboardPath { get; set; } + public string? DashboardUrl { get; set; } + public string? DashboardToken { get; set; } + public string? OtlpEndpointUrl { get; set; } + public string? OtlpApiKey { get; set; } + public string AspNetCoreEnvironment { get; set; } = "Production"; +} + +internal class ConfigureDefaultDashboardOptions(IConfiguration configuration, IOptions dcpOptions) : IConfigureOptions +{ + public void Configure(DashboardOptions options) + { + options.DashboardPath = dcpOptions.Value.DashboardPath; + options.DashboardUrl = configuration["ASPNETCORE_URLS"]; + options.DashboardToken = configuration["AppHost:BrowserToken"]; + + options.OtlpEndpointUrl = configuration["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"]; + options.OtlpApiKey = configuration["AppHost:OtlpApiKey"]; + + options.AspNetCoreEnvironment = configuration["ASPNETCORE_ENVIRONMENT"] ?? "Production"; + } +} + +internal class ValidateDashboardOptions : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, DashboardOptions options) + { + var builder = new ValidateOptionsResultBuilder(); + + if (string.IsNullOrEmpty(options.DashboardUrl)) + { + builder.AddError("Failed to configure dashboard resource because ASPNETCORE_URLS environment variable was not set."); + } + + if (string.IsNullOrEmpty(options.OtlpEndpointUrl)) + { + builder.AddError("Failed to configure dashboard resource because DOTNET_DASHBOARD_OTLP_ENDPOINT_URL environment variable was not set."); + } + + return builder.Build(); + } +} diff --git a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs index a2ff236738..441ba230aa 100644 --- a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs +++ b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs @@ -12,13 +12,13 @@ using Aspire.Hosting.Dashboard; using Aspire.Hosting.Dcp.Model; using Aspire.Hosting.Lifecycle; -using Aspire.Hosting.Utils; using k8s; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Polly; using Polly.Retry; +using Polly.Timeout; namespace Aspire.Hosting.Dcp; @@ -61,12 +61,10 @@ public ServiceAppResource(IResource modelResource, Service service, EndpointAnno internal sealed class ApplicationExecutor(ILogger logger, ILogger distributedApplicationLogger, DistributedApplicationModel model, - DistributedApplicationOptions distributedApplicationOptions, IKubernetesService kubernetesService, IEnumerable lifecycleHooks, IConfiguration configuration, IOptions options, - IDashboardEndpointProvider dashboardEndpointProvider, DistributedApplicationExecutionContext executionContext, ResourceNotificationService notificationService, ResourceLoggerService loggerService, @@ -80,7 +78,6 @@ internal sealed class ApplicationExecutor(ILogger logger, private readonly ILookup _parentChildLookup = GetParentChildLookup(model); private readonly IDistributedApplicationLifecycleHook[] _lifecycleHooks = lifecycleHooks.ToArray(); private readonly IOptions _options = options; - private readonly IDashboardEndpointProvider _dashboardEndpointProvider = dashboardEndpointProvider; private readonly DistributedApplicationExecutionContext _executionContext = executionContext; private readonly List _appResources = []; @@ -109,18 +106,6 @@ public async Task RunApplicationAsync(CancellationToken cancellationToken = defa try { - if (!distributedApplicationOptions.DisableDashboard) - { - if (_model.Resources.SingleOrDefault(r => StringComparers.ResourceName.Equals(r.Name, KnownResourceNames.AspireDashboard)) is not { } dashboardResource) - { - // No dashboard is specified, so start one. - await StartDashboardAsDcpExecutableAsync(cancellationToken).ConfigureAwait(false); - } - else - { - ConfigureAspireDashboardResource(dashboardResource); - } - } PrepareServices(); PrepareContainers(); PrepareExecutables(); @@ -385,7 +370,7 @@ private async Task ProcessResourceChange(WatchEventType watchEventType, T res } else { - // No application model resource found for the DCP resource. This should only happen for the dashboard. + // No application model resource found for the DCP resource. if (_logger.IsEnabled(LogLevel.Trace)) { _logger.LogTrace("No application model resource found for {ResourceKind} resource {ResourceName}", resourceKind, resource.Metadata.Name); @@ -541,11 +526,12 @@ private CustomResourceSnapshot ToSnapshot(Container container, CustomResourceSna var urls = GetUrls(container); var environment = GetEnvironmentVariables(container.Status?.EffectiveEnv ?? container.Spec.Env, container.Spec.Env); + var state = container.AppModelInitialState == KnownResourceStates.Hidden ? KnownResourceStates.Hidden : container.Status?.State; return previous with { ResourceType = KnownResourceTypes.Container, - State = container.Status?.State, + State = state, // Map a container exit code of -1 (unknown) to null ExitCode = container.Status?.ExitCode is null or Conventions.UnknownExitCode ? null : container.Status.ExitCode, Properties = [ @@ -589,6 +575,8 @@ private CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceS projectPath = appModelResource is ProjectResource p ? p.GetProjectMetadata().ProjectPath : null; } + var state = executable.AppModelInitialState is "Hidden" ? "Hidden" : executable.Status?.State; + var urls = GetUrls(executable); var environment = GetEnvironmentVariables(executable.Status?.EffectiveEnv, executable.Spec.Env); @@ -598,7 +586,7 @@ private CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceS return previous with { ResourceType = KnownResourceTypes.Project, - State = executable.Status?.State, + State = state, ExitCode = executable.Status?.ExitCode, Properties = [ new(KnownProperties.Executable.Path, executable.Spec.ExecutablePath), @@ -616,7 +604,7 @@ private CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceS return previous with { ResourceType = KnownResourceTypes.Executable, - State = executable.Status?.State, + State = state, ExitCode = executable.Status?.ExitCode, Properties = [ new(KnownProperties.Executable.Path, executable.Spec.ExecutablePath), @@ -753,156 +741,15 @@ private static bool ProcessResourceChange(ConcurrentDictionary map return true; } - private void ConfigureAspireDashboardResource(IResource dashboardResource) - { - // Don't publish the resource to the manifest. - dashboardResource.Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore); - - // Remove endpoint annotations because we are directly configuring - // the dashboard app (it doesn't go through the proxy!). - var endpointAnnotations = dashboardResource.Annotations.OfType().ToList(); - foreach (var endpointAnnotation in endpointAnnotations) - { - dashboardResource.Annotations.Remove(endpointAnnotation); - } - - dashboardResource.Annotations.Add(new EnvironmentCallbackAnnotation(async context => - { - var env = await GetDashboardEnvironmentVariablesAsync(configuration, defaultDashboardUrl: null, context.CancellationToken).ConfigureAwait(false); - - foreach (var e in env) - { - context.EnvironmentVariables[e.Key] = e.Value; - } - })); - } - - private async Task StartDashboardAsDcpExecutableAsync(CancellationToken cancellationToken = default) - { - if (!distributedApplicationOptions.DashboardEnabled) - { - // The dashboard is disabled. Do nothing. - return; - } - - if (_options.Value.DashboardPath is not { } dashboardPath) - { - throw new DistributedApplicationException("Dashboard path empty or file does not exist."); - } - - var fullyQualifiedDashboardPath = Path.GetFullPath(dashboardPath); - var dashboardWorkingDirectory = Path.GetDirectoryName(fullyQualifiedDashboardPath); - - var dashboardExecutableSpec = new ExecutableSpec - { - ExecutionType = ExecutionType.Process, - WorkingDirectory = dashboardWorkingDirectory - }; - - if (string.Equals(".dll", Path.GetExtension(fullyQualifiedDashboardPath), StringComparison.OrdinalIgnoreCase)) - { - // The dashboard path is a DLL, so run it with `dotnet ` - dashboardExecutableSpec.ExecutablePath = "dotnet"; - dashboardExecutableSpec.Args = [fullyQualifiedDashboardPath]; - } - else - { - // Assume the dashboard path is directly executable - dashboardExecutableSpec.ExecutablePath = fullyQualifiedDashboardPath; - } - - // Matches DashboardWebApplication.DashboardUrlDefaultValue - const string defaultDashboardUrl = "http://localhost:18888"; - - var env = await GetDashboardEnvironmentVariablesAsync(configuration, defaultDashboardUrl: defaultDashboardUrl, cancellationToken).ConfigureAwait(false); - - dashboardExecutableSpec.Env = []; - foreach (var e in env) - { - dashboardExecutableSpec.Env.Add(new() { Name = e.Key, Value = e.Value }); - } - - var dashboardExecutable = new Executable(dashboardExecutableSpec) - { - Metadata = { Name = KnownResourceNames.AspireDashboard } - }; - - await kubernetesService.CreateAsync(dashboardExecutable, cancellationToken).ConfigureAwait(false); - - var dashboardUrls = env.Single(e => e.Key == DashboardConfigNames.DashboardFrontendUrlName.EnvVarName).Value; - var browserToken = env.SingleOrDefault(e => e.Key == DashboardConfigNames.DashboardFrontendBrowserTokenName.EnvVarName).Value; - PrintDashboardUrls(dashboardUrls, browserToken); - } - - private async Task>> GetDashboardEnvironmentVariablesAsync(IConfiguration configuration, string? defaultDashboardUrl, CancellationToken cancellationToken) - { - var dashboardUrls = configuration["ASPNETCORE_URLS"] ?? defaultDashboardUrl; - if (string.IsNullOrEmpty(dashboardUrls)) - { - throw new DistributedApplicationException("Failed to configure dashboard resource because ASPNETCORE_URLS environment variable was not set."); - } - - if (configuration["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"] is not { } otlpEndpointUrl) - { - throw new DistributedApplicationException("Failed to configure dashboard resource because DOTNET_DASHBOARD_OTLP_ENDPOINT_URL environment variable was not set."); - } - - var resourceServiceUrl = await _dashboardEndpointProvider.GetResourceServiceUriAsync(cancellationToken).ConfigureAwait(false); - - var environment = configuration["ASPNETCORE_ENVIRONMENT"] ?? "Production"; - - var env = new List> - { - KeyValuePair.Create("ASPNETCORE_ENVIRONMENT", environment), - KeyValuePair.Create(DashboardConfigNames.DashboardFrontendUrlName.EnvVarName, dashboardUrls), - KeyValuePair.Create(DashboardConfigNames.ResourceServiceUrlName.EnvVarName, resourceServiceUrl), - KeyValuePair.Create(DashboardConfigNames.DashboardOtlpUrlName.EnvVarName, otlpEndpointUrl), - KeyValuePair.Create(DashboardConfigNames.ResourceServiceAuthModeName.EnvVarName, "Unsecured"), - }; - - if (configuration["AppHost:BrowserToken"] is { Length: > 0 } browserToken) - { - env.Add(KeyValuePair.Create(DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName, "BrowserToken")); - env.Add(KeyValuePair.Create(DashboardConfigNames.DashboardFrontendBrowserTokenName.EnvVarName, browserToken)); - } - else - { - env.Add(KeyValuePair.Create(DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName, "Unsecured")); - } - - if (configuration["AppHost:OtlpApiKey"] is { Length: > 0 } otlpApiKey) - { - env.Add(KeyValuePair.Create(DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName, "ApiKey")); - env.Add(KeyValuePair.Create(DashboardConfigNames.DashboardOtlpPrimaryApiKeyName.EnvVarName, otlpApiKey)); - } - else - { - env.Add(KeyValuePair.Create(DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName, "Unsecured")); - } - - return env; - } - - private void PrintDashboardUrls(string delimitedUrlList, string? browserToken) - { - if (StringUtils.TryGetUriFromDelimitedString(delimitedUrlList, ";", out var firstDashboardUrl)) - { - distributedApplicationLogger.LogInformation("Now listening on: {DashboardUrl}", firstDashboardUrl.ToString().TrimEnd('/')); - } - - if (!string.IsNullOrEmpty(browserToken)) - { - LoggingHelpers.WriteDashboardUrl(distributedApplicationLogger, delimitedUrlList, browserToken); - } - } - private async Task CreateServicesAsync(CancellationToken cancellationToken = default) { try { AspireEventSource.Instance.DcpServicesCreationStart(); - var needAddressAllocated = _appResources.OfType().Where(sr => !sr.Service.HasCompleteAddress).ToList(); + var needAddressAllocated = _appResources.OfType() + .Where(sr => !sr.Service.HasCompleteAddress && sr.Service.Spec.AddressAllocationMode != AddressAllocationModes.Proxyless) + .ToList(); await CreateResourcesAsync(cancellationToken).ConfigureAwait(false); @@ -912,26 +759,69 @@ private async Task CreateServicesAsync(CancellationToken cancellationToken = def return; } - // We do not specify the initial list version, so the watcher will give us all updates to Service objects. - IAsyncEnumerable<(WatchEventType, Service)> serviceChangeEnumerator = kubernetesService.WatchAsync(cancellationToken: cancellationToken); - await foreach (var (evt, updated) in serviceChangeEnumerator) + var withTimeout = new TimeoutStrategyOptions() + { + Timeout = _options.Value.ServiceStartupWatchTimeout + }; + + var tryTwice = new RetryStrategyOptions() { - if (evt == WatchEventType.Bookmark) { continue; } // Bookmarks do not contain any data. + BackoffType = DelayBackoffType.Constant, + MaxDelay = TimeSpan.FromSeconds(1), + UseJitter = true, + MaxRetryAttempts = 1, + ShouldHandle = new PredicateBuilder().Handle(), + OnRetry = (retry) => + { + _logger.LogDebug( + retry.Outcome.Exception, + "Watching for service port allocation ended with an error after {WatchDurationMs} (iteration {Iteration})", + retry.Duration.TotalMilliseconds, + retry.AttemptNumber + ); + return ValueTask.CompletedTask; + } + }; - var srvResource = needAddressAllocated.Where(sr => sr.Service.Metadata.Name == updated.Metadata.Name).FirstOrDefault(); - if (srvResource == null) { continue; } // This service most likely already has full address information, so it is not on needAddressAllocated list. + var execution = new ResiliencePipelineBuilder().AddRetry(tryTwice).AddTimeout(withTimeout).Build(); - if (updated.HasCompleteAddress || updated.Spec.AddressAllocationMode == AddressAllocationModes.Proxyless) + await execution.ExecuteAsync(async (attemptCancellationToken) => + { + IAsyncEnumerable<(WatchEventType, Service)> serviceChangeEnumerator = kubernetesService.WatchAsync(cancellationToken: attemptCancellationToken); + await foreach (var (evt, updated) in serviceChangeEnumerator) { - srvResource.Service.ApplyAddressInfoFrom(updated); - needAddressAllocated.Remove(srvResource); + if (evt == WatchEventType.Bookmark) { continue; } // Bookmarks do not contain any data. + + var srvResource = needAddressAllocated.FirstOrDefault(sr => sr.Service.Metadata.Name == updated.Metadata.Name); + if (srvResource == null) { continue; } // This service most likely already has full address information, so it is not on needAddressAllocated list. + + if (updated.HasCompleteAddress) + { + srvResource.Service.ApplyAddressInfoFrom(updated); + needAddressAllocated.Remove(srvResource); + } + + if (needAddressAllocated.Count == 0) + { + return; // We are done + } } + }, cancellationToken).ConfigureAwait(false); - if (needAddressAllocated.Count == 0) + // If there are still services that need address allocated, try a final direct query in case the watch missed some updates. + foreach (var sar in needAddressAllocated) + { + var dcpSvc = await kubernetesService.GetAsync(sar.Service.Metadata.Name, cancellationToken: cancellationToken).ConfigureAwait(false); + if (dcpSvc.HasCompleteAddress) + { + sar.Service.ApplyAddressInfoFrom(dcpSvc); + } + else { - return; // We are done + distributedApplicationLogger.LogWarning("Unable to allocate a network port for service '{ServiceName}'; service may be unreachable and its clients may not work properly.", sar.Service.Metadata.Name); } } + } finally { @@ -1044,6 +934,7 @@ private void PreparePlainExecutables() exe.Spec.ExecutionType = ExecutionType.Process; exe.Annotate(CustomResource.OtelServiceNameAnnotation, exe.Metadata.Name); exe.Annotate(CustomResource.ResourceNameAnnotation, executable.Name); + SetInitialResourceState(executable, exe); var exeAppResource = new AppResource(executable, exe); AddServicesProducedInfo(executable, exe, exeAppResource); @@ -1072,6 +963,8 @@ private void PrepareProjectExecutables() annotationHolder.Annotate(CustomResource.OtelServiceNameAnnotation, ers.Metadata.Name); annotationHolder.Annotate(CustomResource.ResourceNameAnnotation, project.Name); + SetInitialResourceState(project, annotationHolder); + var projectLaunchConfiguration = new ProjectLaunchConfiguration(); projectLaunchConfiguration.ProjectPath = projectMetadata.ProjectPath; @@ -1089,7 +982,7 @@ private void PrepareProjectExecutables() } else { -#pragma warning disable CS0612 // These annotations are obsolete; remove in Aspire Preview 6 +#pragma warning disable CS0612 // These annotations are obsolete; remove after Aspire GA annotationHolder.Annotate(Executable.CSharpProjectPathAnnotation, projectMetadata.ProjectPath); // ExcludeLaunchProfileAnnotation takes precedence over LaunchProfileAnnotation. @@ -1156,23 +1049,22 @@ private void PrepareProjectExecutables() } } + private static void SetInitialResourceState(IResource resource, IAnnotationHolder annotationHolder) + { + // Store the initial state of the resource + if (resource.TryGetLastAnnotation(out var initial) && + initial.InitialSnapshot.State?.Text is string state && !string.IsNullOrEmpty(state)) + { + annotationHolder.Annotate(CustomResource.ResourceStateAnnotation, state); + } + } + private Task CreateExecutablesAsync(IEnumerable executableResources, CancellationToken cancellationToken) { try { AspireEventSource.Instance.DcpExecutablesCreateStart(); - // Hoisting the aspire-dashboard resource if it exists to the top of - // the list so we start it first. - var sortedExecutableResources = executableResources.ToList(); - var (dashboardIndex, dashboardAppResource) = sortedExecutableResources.IndexOf(static r => StringComparers.ResourceName.Equals(r.ModelResource.Name, KnownResourceNames.AspireDashboard)); - - if (dashboardIndex > 0) - { - sortedExecutableResources.RemoveAt(dashboardIndex); - sortedExecutableResources.Insert(0, dashboardAppResource); - } - async Task CreateExecutableAsyncCore(AppResource cr, CancellationToken cancellationToken) { var logger = loggerService.GetLogger(cr.ModelResource); @@ -1205,7 +1097,7 @@ await notificationService.PublishUpdateAsync(cr.ModelResource, s => s with } var tasks = new List(); - foreach (var er in sortedExecutableResources) + foreach (var er in executableResources) { tasks.Add(CreateExecutableAsyncCore(er, cancellationToken)); } @@ -1323,23 +1215,6 @@ private async Task CreateExecutableAsync(AppResource er, ILogger resourceLogger, } await createResource().ConfigureAwait(false); - - // NOTE: This check is only necessary for the inner loop in the dotnet/aspire repo. When - // running in the dotnet/aspire repo we will normally launch the dashboard via - // AddProject. When doing this we make sure that the dashboard is running. - if (!distributedApplicationOptions.DisableDashboard && er.ModelResource.Name.Equals(KnownResourceNames.AspireDashboard, StringComparisons.ResourceName)) - { - // We just check the HTTP endpoint because this will prove that the - // dashboard is listening and is ready to process requests. - if (configuration["ASPNETCORE_URLS"] is not { } dashboardUrls) - { - throw new DistributedApplicationException("Cannot check dashboard availability since ASPNETCORE_URLS environment variable not set."); - } - - var browserToken = configuration["AppHost:BrowserToken"]; - - PrintDashboardUrls(dashboardUrls, browserToken); - } } private async Task GetValue(string? key, IValueProvider valueProvider, ILogger logger, bool isContainer, CancellationToken cancellationToken) @@ -1405,6 +1280,7 @@ private void PrepareContainers() ctr.Annotate(CustomResource.ResourceNameAnnotation, container.Name); ctr.Annotate(CustomResource.OtelServiceNameAnnotation, container.Name); + SetInitialResourceState(container, ctr); if (container.TryGetContainerMounts(out var containerMounts)) { diff --git a/src/Aspire.Hosting/Dcp/DcpOptions.cs b/src/Aspire.Hosting/Dcp/DcpOptions.cs index 7d591f0b66..24968aa22d 100644 --- a/src/Aspire.Hosting/Dcp/DcpOptions.cs +++ b/src/Aspire.Hosting/Dcp/DcpOptions.cs @@ -2,20 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Reflection; -using Aspire.Hosting.Publishing; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; namespace Aspire.Hosting.Dcp; internal sealed class DcpOptions { - private const string DcpCliPathMetadataKey = "DcpCliPath"; - private const string DcpExtensionsPathMetadataKey = "DcpExtensionsPath"; - private const string DcpBinPathMetadataKey = "DcpBinPath"; - private const string DashboardPathMetadataKey = "aspiredashboardpath"; - - public static string DcpPublisher = nameof(DcpPublisher); - /// /// The path to the DCP executable used for Aspire orchestration /// @@ -81,69 +74,94 @@ internal sealed class DcpOptions public int KubernetesConfigReadRetryIntervalMilliseconds { get; set; } = 100; - public void ApplyApplicationConfiguration(DistributedApplicationOptions appOptions, IConfiguration dcpPublisherConfiguration, IConfiguration publishingConfiguration, IConfiguration coreConfiguration) + public TimeSpan ServiceStartupWatchTimeout { get; set; } = TimeSpan.FromSeconds(10); +} + +internal class ValidateDcpOptions : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, DcpOptions options) { - string? publisher = publishingConfiguration[nameof(PublishingOptions.Publisher)]; - if (publisher is not null && publisher != "dcp") + var builder = new ValidateOptionsResultBuilder(); + + if (string.IsNullOrEmpty(options.CliPath)) { - // If DCP is not set as the publisher, don't calculate the DCP config - return; + builder.AddError("The path to the DCP executable used for Aspire orchestration is required.", "CliPath"); } - if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(CliPath)])) + if (string.IsNullOrEmpty(options.DashboardPath)) + { + builder.AddError("The path to the Aspire Dashboard binaries is missing.", "DashboardPath"); + } + + return builder.Build(); + } +} + +internal class ConfigureDefaultDcpOptions( + DistributedApplicationOptions appOptions, + IConfiguration configuration) : IConfigureOptions +{ + private const string DcpCliPathMetadataKey = "DcpCliPath"; + private const string DcpExtensionsPathMetadataKey = "DcpExtensionsPath"; + private const string DcpBinPathMetadataKey = "DcpBinPath"; + private const string DashboardPathMetadataKey = "aspiredashboardpath"; + + public static string DcpPublisher = nameof(DcpPublisher); + + public void Configure(DcpOptions options) + { + var dcpPublisherConfiguration = configuration.GetSection(DcpPublisher); + + if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(options.CliPath)])) { // If an explicit path to DCP was provided from configuration, don't try to resolve via assembly attributes - CliPath = dcpPublisherConfiguration[nameof(CliPath)]; + options.CliPath = dcpPublisherConfiguration[nameof(options.CliPath)]; } else { var assemblyMetadata = appOptions.Assembly?.GetCustomAttributes(); - CliPath = GetMetadataValue(assemblyMetadata, DcpCliPathMetadataKey); - ExtensionsPath = GetMetadataValue(assemblyMetadata, DcpExtensionsPathMetadataKey); - BinPath = GetMetadataValue(assemblyMetadata, DcpBinPathMetadataKey); - DashboardPath = GetMetadataValue(assemblyMetadata, DashboardPathMetadataKey); + options.CliPath = GetMetadataValue(assemblyMetadata, DcpCliPathMetadataKey); + options.ExtensionsPath = GetMetadataValue(assemblyMetadata, DcpExtensionsPathMetadataKey); + options.BinPath = GetMetadataValue(assemblyMetadata, DcpBinPathMetadataKey); + options.DashboardPath = GetMetadataValue(assemblyMetadata, DashboardPathMetadataKey); } - if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(ContainerRuntime)])) + if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(options.ContainerRuntime)])) { - ContainerRuntime = dcpPublisherConfiguration[nameof(ContainerRuntime)]; + options.ContainerRuntime = dcpPublisherConfiguration[nameof(options.ContainerRuntime)]; } else { - ContainerRuntime = coreConfiguration.GetValue("DOTNET_ASPIRE_CONTAINER_RUNTIME"); + options.ContainerRuntime = configuration["DOTNET_ASPIRE_CONTAINER_RUNTIME"]; } - if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(DependencyCheckTimeout)])) + if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(options.DependencyCheckTimeout)])) { - if (int.TryParse(dcpPublisherConfiguration[nameof(DependencyCheckTimeout)], out var timeout)) + if (int.TryParse(dcpPublisherConfiguration[nameof(options.DependencyCheckTimeout)], out var timeout)) { - DependencyCheckTimeout = timeout; + options.DependencyCheckTimeout = timeout; } else { - throw new InvalidOperationException($"Invalid value \"{dcpPublisherConfiguration[nameof(DependencyCheckTimeout)]}\" for \"--dependency-check-timeout\". Exepcted an integer value."); + throw new InvalidOperationException($"Invalid value \"{dcpPublisherConfiguration[nameof(options.DependencyCheckTimeout)]}\" for \"--dependency-check-timeout\". Exepcted an integer value."); } } else { - DependencyCheckTimeout = coreConfiguration.GetValue("DOTNET_ASPIRE_DEPENDENCY_CHECK_TIMEOUT", DependencyCheckTimeout); + options.DependencyCheckTimeout = configuration.GetValue("DOTNET_ASPIRE_DEPENDENCY_CHECK_TIMEOUT", options.DependencyCheckTimeout); } - KubernetesConfigReadRetryCount = dcpPublisherConfiguration.GetValue(nameof(KubernetesConfigReadRetryCount), KubernetesConfigReadRetryCount); - KubernetesConfigReadRetryIntervalMilliseconds = dcpPublisherConfiguration.GetValue(nameof(KubernetesConfigReadRetryIntervalMilliseconds), KubernetesConfigReadRetryIntervalMilliseconds); + options.KubernetesConfigReadRetryCount = dcpPublisherConfiguration.GetValue(nameof(options.KubernetesConfigReadRetryCount), options.KubernetesConfigReadRetryCount); + options.KubernetesConfigReadRetryIntervalMilliseconds = dcpPublisherConfiguration.GetValue(nameof(options.KubernetesConfigReadRetryIntervalMilliseconds), options.KubernetesConfigReadRetryIntervalMilliseconds); - if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(ResourceNameSuffix)])) + if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(options.ResourceNameSuffix)])) { - ResourceNameSuffix = dcpPublisherConfiguration[nameof(ResourceNameSuffix)]; + options.ResourceNameSuffix = dcpPublisherConfiguration[nameof(options.ResourceNameSuffix)]; } - DeleteResourcesOnShutdown = dcpPublisherConfiguration.GetValue(nameof(DeleteResourcesOnShutdown), DeleteResourcesOnShutdown); - RandomizePorts = dcpPublisherConfiguration.GetValue(nameof(RandomizePorts), RandomizePorts); - - if (string.IsNullOrEmpty(CliPath)) - { - throw new InvalidOperationException($"Could not resolve the path to the Aspire application host. The application cannot be run without it."); - } + options.DeleteResourcesOnShutdown = dcpPublisherConfiguration.GetValue(nameof(options.DeleteResourcesOnShutdown), options.DeleteResourcesOnShutdown); + options.RandomizePorts = dcpPublisherConfiguration.GetValue(nameof(options.RandomizePorts), options.RandomizePorts); + options.ServiceStartupWatchTimeout = configuration.GetValue("DOTNET_ASPIRE_SERVICE_STARTUP_WATCH_TIMEOUT", options.ServiceStartupWatchTimeout); } private static string? GetMetadataValue(IEnumerable? assemblyMetadata, string key) diff --git a/src/Aspire.Hosting/Dcp/KubernetesService.cs b/src/Aspire.Hosting/Dcp/KubernetesService.cs index 7ded2d939b..dc24421f4b 100644 --- a/src/Aspire.Hosting/Dcp/KubernetesService.cs +++ b/src/Aspire.Hosting/Dcp/KubernetesService.cs @@ -23,10 +23,13 @@ internal enum DcpApiOperationType Delete = 3, Watch = 4, GetLogSubresource = 5, + Get = 6, } internal interface IKubernetesService { + Task GetAsync(string name, string? namespaceParameter = null, CancellationToken cancellationToken = default) + where T: CustomResource; Task CreateAsync(T obj, CancellationToken cancellationToken = default) where T : CustomResource; Task> ListAsync(string? namespaceParameter = null, CancellationToken cancellationToken = default) @@ -54,6 +57,36 @@ internal sealed class KubernetesService(ILogger logger, IOpti public TimeSpan MaxRetryDuration { get; set; } = TimeSpan.FromSeconds(20); + public Task GetAsync(string name, string? namespaceParameter = null, CancellationToken cancellationToken = default) + where T : CustomResource + { + var resourceType = GetResourceFor(); + + return ExecuteWithRetry( + DcpApiOperationType.Get, + resourceType, + async (kubernetes) => + { + var response = string.IsNullOrEmpty(namespaceParameter) + ? await kubernetes.CustomObjects.GetClusterCustomObjectWithHttpMessagesAsync( + GroupVersion.Group, + GroupVersion.Version, + resourceType, + name, + cancellationToken: cancellationToken).ConfigureAwait(false) + : await kubernetes.CustomObjects.GetNamespacedCustomObjectWithHttpMessagesAsync( + GroupVersion.Group, + GroupVersion.Version, + namespaceParameter, + resourceType, + name, + cancellationToken: cancellationToken).ConfigureAwait(false); + + return KubernetesJson.Deserialize(response.Body.ToString()); + }, + cancellationToken); + } + public Task CreateAsync(T obj, CancellationToken cancellationToken = default) where T : CustomResource { diff --git a/src/Aspire.Hosting/Dcp/Model/ModelCommon.cs b/src/Aspire.Hosting/Dcp/Model/ModelCommon.cs index f94a860a6a..43b04675d1 100644 --- a/src/Aspire.Hosting/Dcp/Model/ModelCommon.cs +++ b/src/Aspire.Hosting/Dcp/Model/ModelCommon.cs @@ -23,9 +23,12 @@ internal abstract class CustomResource : KubernetesObject, IMetadata Metadata.Annotations?.TryGetValue(ResourceNameAnnotation, out var value) is true ? value : null; + public string? AppModelInitialState => Metadata.Annotations?.TryGetValue(ResourceStateAnnotation, out var value) is true ? value : null; + [JsonPropertyName("metadata")] public V1ObjectMeta Metadata { get; set; } = new V1ObjectMeta(); diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index e0859c8a01..076cc66431 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -86,31 +86,11 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) ["AppHost:Directory"] = AppHostDirectory }); - if (!options.DisableDashboard && !IsDashboardUnsecured(_innerBuilder.Configuration)) + ExecutionContext = _innerBuilder.Configuration["Publishing:Publisher"] switch { - // Set a random API key for the OTLP exporter. - // Passed to apps as a standard OTEL attribute to include in OTLP requests and the dashboard to validate. - _innerBuilder.Configuration.AddInMemoryCollection( - new Dictionary - { - ["AppHost:OtlpApiKey"] = TokenGenerator.GenerateToken() - } - ); - - if (_innerBuilder.Configuration[KnownConfigNames.DashboardFrontendBrowserToken] is not { Length: > 0 } browserToken) - { - browserToken = TokenGenerator.GenerateToken(); - } - - // Set a random API key for the OTLP exporter. - // Passed to apps as a standard OTEL attribute to include in OTLP requests and the dashboard to validate. - _innerBuilder.Configuration.AddInMemoryCollection( - new Dictionary - { - ["AppHost:BrowserToken"] = browserToken - } - ); - } + "manifest" => new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish), + _ => new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run) + }; // Core things _innerBuilder.Services.AddSingleton(sp => new DistributedApplicationModel(Resources)); @@ -120,35 +100,64 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); - // Dashboard - _innerBuilder.Services.AddOptions().ValidateOnStart().PostConfigure(MapTransportOptionsFromCustomKeys); - _innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, TransportOptionsValidator>()); - _innerBuilder.Services.AddSingleton(); - _innerBuilder.Services.AddHostedService(sp => sp.GetRequiredService()); - _innerBuilder.Services.AddLifecycleHook(); + if (ExecutionContext.IsRunMode) + { + // Dashboard + if (!options.DisableDashboard) + { + if (!IsDashboardUnsecured(_innerBuilder.Configuration)) + { + // Set a random API key for the OTLP exporter. + // Passed to apps as a standard OTEL attribute to include in OTLP requests and the dashboard to validate. + _innerBuilder.Configuration.AddInMemoryCollection( + new Dictionary + { + ["AppHost:OtlpApiKey"] = TokenGenerator.GenerateToken() + } + ); + + if (_innerBuilder.Configuration[KnownConfigNames.DashboardFrontendBrowserToken] is not { Length: > 0 } browserToken) + { + browserToken = TokenGenerator.GenerateToken(); + } + + // Set a random API key for the OTLP exporter. + // Passed to apps as a standard OTEL attribute to include in OTLP requests and the dashboard to validate. + _innerBuilder.Configuration.AddInMemoryCollection( + new Dictionary + { + ["AppHost:BrowserToken"] = browserToken + } + ); + } - // DCP stuff - _innerBuilder.Services.AddSingleton(); - _innerBuilder.Services.AddSingleton(); - _innerBuilder.Services.AddSingleton(); - _innerBuilder.Services.AddHostedService(); + _innerBuilder.Services.AddOptions().ValidateOnStart().PostConfigure(MapTransportOptionsFromCustomKeys); + _innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, TransportOptionsValidator>()); + _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddHostedService(sp => sp.GetRequiredService()); + _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddLifecycleHook(); + _innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, ConfigureDefaultDashboardOptions>()); + _innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, ValidateDashboardOptions>()); + } + + // DCP stuff + _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddHostedService(); + _innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, ConfigureDefaultDcpOptions>()); + _innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, ValidateDcpOptions>()); - // We need a unique path per application instance - _innerBuilder.Services.AddSingleton(new Locations()); - _innerBuilder.Services.AddSingleton(); + // We need a unique path per application instance + _innerBuilder.Services.AddSingleton(new Locations()); + _innerBuilder.Services.AddSingleton(); + } // Publishing support _innerBuilder.Services.AddLifecycleHook(); _innerBuilder.Services.AddKeyedSingleton("manifest"); - _innerBuilder.Services.AddKeyedSingleton("dcp"); - - ExecutionContext = _innerBuilder.Configuration["Publishing:Publisher"] switch - { - "manifest" => new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish), - _ => new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run) - }; - _innerBuilder.Services.AddSingleton(ExecutionContext); + _innerBuilder.Services.AddSingleton(ExecutionContext); LogBuilderConstructed(this); } @@ -177,14 +186,6 @@ private void ConfigurePublishingOptions(DistributedApplicationOptions options) }; _innerBuilder.Configuration.AddCommandLine(options.Args ?? [], switchMappings); _innerBuilder.Services.Configure(_innerBuilder.Configuration.GetSection(PublishingOptions.Publishing)); - _innerBuilder.Services.Configure( - o => o.ApplyApplicationConfiguration( - options, - dcpPublisherConfiguration: _innerBuilder.Configuration.GetSection(DcpOptions.DcpPublisher), - publishingConfiguration: _innerBuilder.Configuration.GetSection(PublishingOptions.Publishing), - coreConfiguration: _innerBuilder.Configuration - ) - ); } /// diff --git a/src/Aspire.Hosting/DistributedApplicationRunner.cs b/src/Aspire.Hosting/DistributedApplicationRunner.cs index 7109b1914b..a92e29bccf 100644 --- a/src/Aspire.Hosting/DistributedApplicationRunner.cs +++ b/src/Aspire.Hosting/DistributedApplicationRunner.cs @@ -10,12 +10,13 @@ namespace Aspire.Hosting; internal sealed class DistributedApplicationRunner(DistributedApplicationExecutionContext executionContext, DistributedApplicationModel model, IServiceProvider serviceProvider) : BackgroundService { - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + protected override Task ExecuteAsync(CancellationToken stoppingToken) { - var publisher = executionContext.IsPublishMode - ? serviceProvider.GetRequiredKeyedService("manifest") - : serviceProvider.GetRequiredKeyedService("dcp"); + if (executionContext.IsPublishMode) + { + return serviceProvider.GetRequiredKeyedService("manifest").PublishAsync(model, stoppingToken); + } - await publisher.PublishAsync(model, stoppingToken).ConfigureAwait(false); + return Task.CompletedTask; } } diff --git a/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs b/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs index be8b833b94..99727513eb 100644 --- a/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs @@ -45,7 +45,7 @@ internal static IResourceBuilder AddParameter(this IDistribut { ResourceType = "Parameter", // hide parameters by default - State = "Hidden", + State = KnownResourceStates.Hidden, Properties = [ new("parameter.secret", secret.ToString()), new(CustomResourceKnownProperties.Source, connectionString ? $"ConnectionStrings:{name}" : $"Parameters:{name}") diff --git a/src/Aspire.Hosting/Publishing/DcpPublisher.cs b/src/Aspire.Hosting/Publishing/DcpPublisher.cs deleted file mode 100644 index afbde26191..0000000000 --- a/src/Aspire.Hosting/Publishing/DcpPublisher.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Hosting.ApplicationModel; - -namespace Aspire.Hosting.Publishing; - -internal sealed class DcpPublisher : IDistributedApplicationPublisher -{ - public Task PublishAsync(DistributedApplicationModel model, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } -} diff --git a/tests/Aspire.Hosting.Tests/AsHttp2ServiceTests.cs b/tests/Aspire.Hosting.Tests/AsHttp2ServiceTests.cs index 3342faf7a6..26b0289c88 100644 --- a/tests/Aspire.Hosting.Tests/AsHttp2ServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/AsHttp2ServiceTests.cs @@ -71,5 +71,5 @@ public void Http2TransportIsNotAppliedToNonHttpEndpoints() Assert.Equal("http2", httpsBinding.Transport); } - private static TestProgram CreateTestProgram(string[] args) => TestProgram.Create(args); + private static TestProgram CreateTestProgram(string[] args) => TestProgram.Create(args, disableDashboard: true); } diff --git a/tests/Aspire.Hosting.Tests/Dapr/DaprTests.cs b/tests/Aspire.Hosting.Tests/Dapr/DaprTests.cs index 54f40969e0..e423ba1b05 100644 --- a/tests/Aspire.Hosting.Tests/Dapr/DaprTests.cs +++ b/tests/Aspire.Hosting.Tests/Dapr/DaprTests.cs @@ -14,7 +14,11 @@ public class DaprTests [Fact] public async Task WithDaprSideCarAddsAnnotationAndSidecarResource() { - using var builder = TestDistributedApplicationBuilder.Create(); + using var builder = TestDistributedApplicationBuilder.Create(new DistributedApplicationOptions + { + DisableDashboard = true + }); + builder.AddDapr(o => { // Fake path to avoid throwing diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs new file mode 100644 index 0000000000..d760015c87 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs @@ -0,0 +1,300 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Dcp; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Aspire.Hosting.Tests.Dashboard; + +public class DashboardResourceTests +{ + [Fact] + public async Task DashboardIsAutomaticallyAddedAsHiddenResource() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dashboardPath = Path.GetFullPath("dashboard"); + + builder.Services.Configure(o => + { + o.DashboardPath = dashboardPath; + }); + + var app = builder.Build(); + + await app.ExecuteBeforeStartHooksAsync(default); + + var model = app.Services.GetRequiredService(); + + var dashboard = Assert.Single(model.Resources.OfType()); + var initialSnapshot = Assert.Single(dashboard.Annotations.OfType()); + + Assert.NotNull(dashboard); + Assert.Equal("aspire-dashboard", dashboard.Name); + Assert.Equal(dashboardPath, dashboard.Command); + Assert.Equal("Hidden", initialSnapshot.InitialSnapshot.State); + } + + [Fact] + public async Task DashboardIsAddedFirst() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.AddContainer("my-container", "my-image"); + + var app = builder.Build(); + + await app.ExecuteBeforeStartHooksAsync(default); + + var model = app.Services.GetRequiredService(); + + Assert.Collection(model.Resources, + r => Assert.Equal("aspire-dashboard", r.Name), + r => Assert.Equal("my-container", r.Name) + ); + } + + [Fact] + public async Task DashboardDoesNotAddResource_ConfiguresExistingDashboard() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.Services.AddSingleton(); + + builder.Configuration.Sources.Clear(); + + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["ASPNETCORE_URLS"] = "http://localhost", + ["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost" + }); + + var container = builder.AddContainer(KnownResourceNames.AspireDashboard, "my-image"); + + var app = builder.Build(); + + await app.ExecuteBeforeStartHooksAsync(default); + + var model = app.Services.GetRequiredService(); + + var dashboard = Assert.Single(model.Resources); + + Assert.Same(container.Resource, dashboard); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dashboard); + + Assert.Collection(config, + e => + { + Assert.Equal("ASPNETCORE_ENVIRONMENT", e.Key); + Assert.Equal("Production", e.Value); + }, + e => + { + Assert.Equal("ASPNETCORE_URLS", e.Key); + Assert.Equal("http://localhost", e.Value); + }, + e => + { + Assert.Equal("DOTNET_RESOURCE_SERVICE_ENDPOINT_URL", e.Key); + Assert.Equal("http://localhost:5000", e.Value); + }, + e => + { + Assert.Equal("DOTNET_DASHBOARD_OTLP_ENDPOINT_URL", e.Key); + Assert.Equal("http://localhost", e.Value); + }, + e => + { + Assert.Equal("DASHBOARD__RESOURCESERVICECLIENT__AUTHMODE", e.Key); + Assert.Equal("Unsecured", e.Value); + }, + e => + { + Assert.Equal("DASHBOARD__FRONTEND__AUTHMODE", e.Key); + Assert.Equal("Unsecured", e.Value); + }, + e => + { + Assert.Equal("DASHBOARD__OTLP__AUTHMODE", e.Key); + Assert.Equal("Unsecured", e.Value); + } + ); + } + + [Fact] + public async Task DashboardWithDllPathLaunchesDotnet() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dashboardPath = Path.GetFullPath("dashboard.dll"); + + builder.Services.Configure(o => + { + o.DashboardPath = dashboardPath; + }); + + var app = builder.Build(); + + await app.ExecuteBeforeStartHooksAsync(default); + + var model = app.Services.GetRequiredService(); + + var dashboard = Assert.Single(model.Resources.OfType()); + + var args = await ArgumentEvaluator.GetArgumentListAsync(dashboard); + + Assert.NotNull(dashboard); + Assert.Equal("aspire-dashboard", dashboard.Name); + Assert.Equal("dotnet", dashboard.Command); + Assert.Equal([dashboardPath], args); + } + + [Fact] + public async Task DashboardAuthConfigured_EnvVarsPresent() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.Services.AddSingleton(); + + builder.Configuration.Sources.Clear(); + + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["ASPNETCORE_URLS"] = "http://localhost", + ["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost", + ["AppHost:BrowserToken"] = "TestBrowserToken!", + ["AppHost:OtlpApiKey"] = "TestOtlpApiKey!" + }); + + var app = builder.Build(); + + await app.ExecuteBeforeStartHooksAsync(default); + + var model = app.Services.GetRequiredService(); + + var dashboard = Assert.Single(model.Resources); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dashboard); + + Assert.Equal("BrowserToken", config.Single(e => e.Key == DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName).Value); + Assert.Equal("TestBrowserToken!", config.Single(e => e.Key == DashboardConfigNames.DashboardFrontendBrowserTokenName.EnvVarName).Value); + + Assert.Equal("ApiKey", config.Single(e => e.Key == DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName).Value); + Assert.Equal("TestOtlpApiKey!", config.Single(e => e.Key == DashboardConfigNames.DashboardOtlpPrimaryApiKeyName.EnvVarName).Value); + } + + [Fact] + public async Task DashboardAuthRemoved_EnvVarsUnsecured() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.Services.AddSingleton(); + + builder.Configuration.Sources.Clear(); + + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["ASPNETCORE_URLS"] = "http://localhost", + ["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost" + }); + + var app = builder.Build(); + + await app.ExecuteBeforeStartHooksAsync(default); + + var model = app.Services.GetRequiredService(); + + var dashboard = Assert.Single(model.Resources); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dashboard); + + Assert.Equal("Unsecured", config.Single(e => e.Key == DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName).Value); + Assert.Equal("Unsecured", config.Single(e => e.Key == DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName).Value); + } + + [Fact] + public async Task DashboardResourceServiceUriIsSet() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.Services.AddSingleton(); + + builder.Configuration.Sources.Clear(); + + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["ASPNETCORE_URLS"] = "http://localhost", + ["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost" + }); + + var app = builder.Build(); + + await app.ExecuteBeforeStartHooksAsync(default); + + var model = app.Services.GetRequiredService(); + + var dashboard = Assert.Single(model.Resources); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dashboard); + + Assert.Equal("http://localhost:5000", config.Single(e => e.Key == DashboardConfigNames.ResourceServiceUrlName.EnvVarName).Value); + } + + [Fact] + public async Task DashboardIsNotAddedInPublishMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var app = builder.Build(); + + await app.ExecuteBeforeStartHooksAsync(default); + + var model = app.Services.GetRequiredService(); + + Assert.Empty(model.Resources); + } + + [Fact] + public async Task DashboardIsNotAddedIfDisabled() + { + using var builder = TestDistributedApplicationBuilder.Create(new DistributedApplicationOptions { DisableDashboard = true }); + + var app = builder.Build(); + + await app.ExecuteBeforeStartHooksAsync(default); + + var model = app.Services.GetRequiredService(); + + Assert.Empty(model.Resources); + } + + [Fact] + public void ContainerIsValidWithDashboardIsDisabled() + { + // Set the host environment to "Development" so that the container validates services. + using var builder = TestDistributedApplicationBuilder.Create(new DistributedApplicationOptions + { + DisableDashboard = true, + Args = ["--environment", "Development"] } + ); + + // Container validation logic runs when the service provider is built. + using var app = builder.Build(); + } + + private sealed class MockDashboardEndpointProvider : IDashboardEndpointProvider + { + public Task GetResourceServiceUriAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult("http://localhost:5000"); + } + } +} diff --git a/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs index 0ff69cd516..e0dfe28bb2 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs @@ -14,71 +14,6 @@ namespace Aspire.Hosting.Tests.Dcp; public class ApplicationExecutorTests { - [Fact] - public async Task RunApplicationAsync_NoResources_DashboardStarted() - { - // Arrange - var distributedAppModel = new DistributedApplicationModel(new ResourceCollection()); - var kubernetesService = new MockKubernetesService(); - - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); - - // Act - await appExecutor.RunApplicationAsync(); - - // Assert - var dashboard = Assert.IsType(Assert.Single(kubernetesService.CreatedResources)); - Assert.Equal("aspire-dashboard", dashboard.Metadata.Name); - } - - [Fact] - public async Task RunApplicationAsync_AuthConfigured_EnvVarsPresent() - { - // Arrange - var distributedAppModel = new DistributedApplicationModel(new ResourceCollection()); - var kubernetesService = new MockKubernetesService(); - - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); - - // Act - await appExecutor.RunApplicationAsync(); - - // Assert - var dashboard = Assert.IsType(Assert.Single(kubernetesService.CreatedResources)); - Assert.NotNull(dashboard.Spec.Env); - - Assert.Equal("BrowserToken", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName).Value); - Assert.Equal("TestBrowserToken!", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.DashboardFrontendBrowserTokenName.EnvVarName).Value); - - Assert.Equal("ApiKey", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName).Value); - Assert.Equal("TestOtlpApiKey!", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.DashboardOtlpPrimaryApiKeyName.EnvVarName).Value); - } - - [Fact] - public async Task RunApplicationAsync_AuthRemoved_EnvVarsUnsecured() - { - // Arrange - var distributedAppModel = new DistributedApplicationModel(new ResourceCollection()); - var kubernetesService = new MockKubernetesService(); - var builder = new ConfigurationBuilder(); - builder.AddInMemoryCollection(new Dictionary - { - ["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost" - }); - - var appExecutor = CreateAppExecutor(distributedAppModel, configuration: builder.Build(), kubernetesService: kubernetesService); - - // Act - await appExecutor.RunApplicationAsync(); - - // Assert - var dashboard = Assert.IsType(Assert.Single(kubernetesService.CreatedResources)); - Assert.NotNull(dashboard.Spec.Env); - - Assert.Equal("Unsecured", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName).Value); - Assert.Equal("Unsecured", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName).Value); - } - [Fact] public async Task ContainersArePassedOtelServiceName() { @@ -122,7 +57,6 @@ private static ApplicationExecutor CreateAppExecutor( NullLogger.Instance, NullLogger.Instance, distributedAppModel, - new DistributedApplicationOptions(), kubernetesService ?? new MockKubernetesService(), Array.Empty(), configuration, @@ -130,7 +64,6 @@ private static ApplicationExecutor CreateAppExecutor( { DashboardPath = "./dashboard" }), - new MockDashboardEndpointProvider(), new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), new ResourceNotificationService(new NullLogger()), new ResourceLoggerService(), diff --git a/tests/Aspire.Hosting.Tests/Dcp/MockDashboardEndpointProvider.cs b/tests/Aspire.Hosting.Tests/Dcp/MockDashboardEndpointProvider.cs deleted file mode 100644 index 99a0ed7140..0000000000 --- a/tests/Aspire.Hosting.Tests/Dcp/MockDashboardEndpointProvider.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Hosting.Dcp; - -namespace Aspire.Hosting.Tests.Dcp; - -internal sealed class MockDashboardEndpointProvider : IDashboardEndpointProvider -{ - public Task GetResourceServiceUriAsync(CancellationToken cancellationToken = default) - { - return Task.FromResult("http://localhost:5000"); - } -} diff --git a/tests/Aspire.Hosting.Tests/Dcp/MockKubernetesService.cs b/tests/Aspire.Hosting.Tests/Dcp/MockKubernetesService.cs index 376868424f..df41274b8b 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/MockKubernetesService.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/MockKubernetesService.cs @@ -11,7 +11,20 @@ internal sealed class MockKubernetesService : IKubernetesService { internal sealed record DeletedResource(Type Type, object Value); - public List CreatedResources { get; } = []; + public List CreatedResources { get; } = []; + + public Task GetAsync(string name, string? namespaceParameter = null, CancellationToken _ = default) where T : CustomResource + { + var res = CreatedResources.OfType().FirstOrDefault(r => + r.Metadata.Name == name && + string.Equals(r.Metadata.NamespaceProperty ?? string.Empty, namespaceParameter ?? string.Empty) + ); + if (res == null) + { + throw new ArgumentException($"Resource '{namespaceParameter ?? ""}/{name}' not found"); + } + return Task.FromResult(res); + } public Task CreateAsync(T obj, CancellationToken cancellationToken = default) where T : CustomResource { diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs index 87bc1679e6..e99a77d7bb 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Hosting.Dcp; using Aspire.Hosting.Lifecycle; using Aspire.Hosting.Publishing; using Microsoft.Extensions.Configuration; @@ -25,10 +26,16 @@ public void BuilderExecutionContextExposesCorrectOperation(string[] args, Distri public void BuilderAddsDefaultServices() { var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.Services.Configure(o => + { + o.DashboardPath = "dashboard"; + o.CliPath = "dcp"; + }); + using var app = appBuilder.Build(); Assert.NotNull(app.Services.GetRequiredKeyedService("manifest")); - Assert.NotNull(app.Services.GetRequiredKeyedService("dcp")); var appModel = app.Services.GetRequiredService(); Assert.Empty(appModel.Resources); diff --git a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs index 6074e2cdd0..81d022b577 100644 --- a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs @@ -15,7 +15,9 @@ public class ProjectResourceTests [Fact] public async Task AddProjectAddsEnvironmentVariablesAndServiceMetadata() { - var appBuilder = CreateBuilder(); + // Explicitly specify development environment and other config so it is constant. + var appBuilder = CreateBuilder(args: ["--environment", "Development", "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL=http://localhost:18889"], + DistributedApplicationOperation.Run); appBuilder.AddProject("projectName", launchProfileName: null); using var app = appBuilder.Build(); @@ -25,7 +27,6 @@ public async Task AddProjectAddsEnvironmentVariablesAndServiceMetadata() var resource = Assert.Single(projectResources); Assert.Equal("projectName", resource.Name); - Assert.Equal(8, resource.Annotations.Count); var serviceMetadata = Assert.Single(resource.Annotations.OfType()); Assert.IsType(serviceMetadata); @@ -100,7 +101,7 @@ public async Task AddProjectAddsEnvironmentVariablesAndServiceMetadata() [InlineData(null, true)] public async Task AddProjectAddsEnvironmentVariablesAndServiceMetadata_OtlpAuthDisabledSetting(string? value, bool hasHeader) { - var appBuilder = CreateBuilder(args: [$"DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS={value}"]); + var appBuilder = CreateBuilder(args: [$"DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS={value}"], DistributedApplicationOperation.Run); appBuilder.AddProject("projectName", launchProfileName: null); using var app = appBuilder.Build(); diff --git a/tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs b/tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs index ad8f83447e..a7ea52d91d 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Hosting.Dashboard; +using Aspire.Hosting.Dcp; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -27,16 +29,36 @@ public sealed class TestDistributedApplicationBuilder : IDisposable, IDistribute public static TestDistributedApplicationBuilder Create(DistributedApplicationOperation operation = DistributedApplicationOperation.Run) { - if (operation == DistributedApplicationOperation.Publish) + var args = operation switch { - var options = new DistributedApplicationOptions - { - Args = ["Publishing:Publisher=manifest"] - }; - return new(DistributedApplication.CreateBuilder(options)); - } + DistributedApplicationOperation.Run => (string[])[], + DistributedApplicationOperation.Publish => ["Publishing:Publisher=manifest"], + _ => throw new ArgumentOutOfRangeException(nameof(operation)) + }; + + return Create(new DistributedApplicationOptions { Args = args }); + } + + public static TestDistributedApplicationBuilder Create(DistributedApplicationOptions options) + { + // Fake dashboard and CLI paths to avoid throwing on build + var builder = DistributedApplication.CreateBuilder(options); + + builder.Services.Configure(o => + { + // Make sure we have a dashboard path and CLI path (but don't overwrite them if they're already set) + o.DashboardPath ??= "dashboard"; + o.CliPath ??= "dcp"; + }); + + builder.Services.Configure(o => + { + // Make sure we have a dashboard URL and OTLP endpoint URL (but don't overwrite them if they're already set) + o.DashboardUrl ??= "http://localhost:8080"; + o.OtlpEndpointUrl ??= "http://localhost:4317"; + }); - return new(DistributedApplication.CreateBuilder()); + return new(builder); } private TestDistributedApplicationBuilder(IDistributedApplicationBuilder builder)