diff --git a/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs b/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs index 657f44fb4be..16c901712c3 100644 --- a/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs +++ b/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs @@ -13,18 +13,24 @@ internal class ArgumentsExecutionConfigurationGatherer : IExecutionConfiguration /// public async ValueTask GatherAsync(IExecutionConfigurationGathererContext context, IResource resource, ILogger resourceLogger, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default) { - if (resource.TryGetAnnotationsOfType(out var callbacks)) + if (resource.TryGetAnnotationsOfType(out var argumentAnnotations)) { - var callbackContext = new CommandLineArgsCallbackContext(context.Arguments, resource, cancellationToken) + IList args = [.. context.Arguments]; + var callbackContext = new CommandLineArgsCallbackContext(args, resource, cancellationToken) { Logger = resourceLogger, ExecutionContext = executionContext }; - foreach (var callback in callbacks) + foreach (var ann in argumentAnnotations) { - await callback.Callback(callbackContext).ConfigureAwait(false); + // Each annotation operates on a shared context. + args = await ann.AsCallbackAnnotation().EvaluateOnceAsync(callbackContext).ConfigureAwait(false); } + + // Take the final result and apply to the gatherer context. + context.Arguments.Clear(); + context.Arguments.AddRange(args); } } } \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/CommandLineArgsCallbackAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/CommandLineArgsCallbackAnnotation.cs index 9413d50ec1b..aa3485114cb 100644 --- a/src/Aspire.Hosting/ApplicationModel/CommandLineArgsCallbackAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/CommandLineArgsCallbackAnnotation.cs @@ -1,16 +1,22 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Immutable; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Hosting.ApplicationModel; +using IArgCallbackAnnotation = ICallbackResourceAnnotation>; + /// /// Represents an annotation that provides a callback to be executed with a list of command-line arguments when an executable resource is started. /// -public class CommandLineArgsCallbackAnnotation : IResourceAnnotation +public class CommandLineArgsCallbackAnnotation : IResourceAnnotation, IArgCallbackAnnotation { + private Task>? _callbackTask; + private readonly object _lock = new(); + /// /// Initializes a new instance of the class with the specified callback action. /// @@ -41,6 +47,35 @@ public CommandLineArgsCallbackAnnotation(Action> callback) /// Gets the callback action to be executed when the executable arguments are parsed. /// public Func Callback { get; } + + internal IArgCallbackAnnotation AsCallbackAnnotation() => this; + + Task> IArgCallbackAnnotation.EvaluateOnceAsync(CommandLineArgsCallbackContext context) + { + lock(_lock) + { + if (_callbackTask is null) + { + _callbackTask = ExecuteCallbackAsync(context); + } + return _callbackTask; + } + } + + void IArgCallbackAnnotation.ForgetCachedResult() + { + lock(_lock) + { + _callbackTask = null; + } + } + + private async Task> ExecuteCallbackAsync(CommandLineArgsCallbackContext context) + { + await Callback(context).ConfigureAwait(false); + var result = context.Args.ToImmutableList(); + return result; + } } /// diff --git a/src/Aspire.Hosting/ApplicationModel/EnvironmentCallbackAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/EnvironmentCallbackAnnotation.cs index e252bae0d2c..410f0282ef8 100644 --- a/src/Aspire.Hosting/ApplicationModel/EnvironmentCallbackAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/EnvironmentCallbackAnnotation.cs @@ -5,13 +5,17 @@ namespace Aspire.Hosting.ApplicationModel; +using IEnvCallbackAnnotation = ICallbackResourceAnnotation>; + /// /// Represents an annotation that provides a callback to modify the environment variables of an application. /// [DebuggerDisplay("{DebuggerToString(),nq}")] -public class EnvironmentCallbackAnnotation : IResourceAnnotation +public class EnvironmentCallbackAnnotation : IResourceAnnotation, IEnvCallbackAnnotation { private readonly string? _name; + private Task>? _callbackTask; + private readonly object _lock = new(); /// /// Initializes a new instance of the class with the specified name and callback function. @@ -77,6 +81,35 @@ public EnvironmentCallbackAnnotation(Func call /// public Func Callback { get; private set; } + internal IEnvCallbackAnnotation AsCallbackAnnotation() => this; + + Task> IEnvCallbackAnnotation.EvaluateOnceAsync(EnvironmentCallbackContext context) + { + lock(_lock) + { + if (_callbackTask is null) + { + _callbackTask = ExecuteCallbackAsync(context); + } + return _callbackTask; + } + } + + void IEnvCallbackAnnotation.ForgetCachedResult() + { + lock(_lock) + { + _callbackTask = null; + } + } + + private async Task> ExecuteCallbackAsync(EnvironmentCallbackContext context) + { + await Callback(context).ConfigureAwait(false); + var result = new Dictionary(context.EnvironmentVariables); + return result; + } + private string DebuggerToString() { var text = $@"Type = {GetType().Name}"; diff --git a/src/Aspire.Hosting/ApplicationModel/EnvironmentVariablesConfigurationGatherer.cs b/src/Aspire.Hosting/ApplicationModel/EnvironmentVariablesConfigurationGatherer.cs index 26d8324f480..37a5c3685ba 100644 --- a/src/Aspire.Hosting/ApplicationModel/EnvironmentVariablesConfigurationGatherer.cs +++ b/src/Aspire.Hosting/ApplicationModel/EnvironmentVariablesConfigurationGatherer.cs @@ -13,16 +13,24 @@ internal class EnvironmentVariablesExecutionConfigurationGatherer : IExecutionCo /// public async ValueTask GatherAsync(IExecutionConfigurationGathererContext context, IResource resource, ILogger resourceLogger, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default) { - if (resource.TryGetEnvironmentVariables(out var callbacks)) + if (resource.TryGetEnvironmentVariables(out var envVarAnnotations)) { - var callbackContext = new EnvironmentCallbackContext(executionContext, resource, context.EnvironmentVariables, cancellationToken) + var envVars = new Dictionary(context.EnvironmentVariables); + var callbackContext = new EnvironmentCallbackContext(executionContext, resource, envVars, cancellationToken: cancellationToken) { Logger = resourceLogger, }; - foreach (var callback in callbacks) + foreach (var ann in envVarAnnotations) { - await callback.Callback(callbackContext).ConfigureAwait(false); + // Each annotation operates on a shared context. + envVars = await ann.AsCallbackAnnotation().EvaluateOnceAsync(callbackContext).ConfigureAwait(false); + } + + // Take the final result and apply to the gatherer context. + foreach (var kvp in envVars) + { + context.EnvironmentVariables[kvp.Key] = kvp.Value; } } } diff --git a/src/Aspire.Hosting/ApplicationModel/ICallbackResourceAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ICallbackResourceAnnotation.cs new file mode 100644 index 00000000000..767cf003cc4 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ICallbackResourceAnnotation.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a resource annotation whose callback should be evaluated at most once, +/// with the result cached for subsequent retrievals. +/// +/// The type of the context passed to the callback. +/// The type of the result produced by the callback. +internal interface ICallbackResourceAnnotation +{ + /// + /// Evaluates the callback if it has not been evaluated yet, caching the result. + /// Subsequent calls return the cached result regardless of the context passed. + /// + /// The context for the callback evaluation. Only used on the first call. + /// The cached result of the callback evaluation. + Task EvaluateOnceAsync(TContext context); + + /// + /// Clears the cached result so that the next call to will re-execute the callback. + /// + /// + /// Use when a resource decorated with this callback annotation is restarted. + /// + void ForgetCachedResult(); +} diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceDependencyDiscoveryMode.cs b/src/Aspire.Hosting/ApplicationModel/ResourceDependencyDiscoveryMode.cs index 41dc043175d..5526b222108 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceDependencyDiscoveryMode.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceDependencyDiscoveryMode.cs @@ -6,13 +6,14 @@ namespace Aspire.Hosting.ApplicationModel; /// /// Specifies how resource dependencies are discovered. /// +[Flags] public enum ResourceDependencyDiscoveryMode { /// /// Discover the full transitive closure of all dependencies. /// This includes direct dependencies and all dependencies of those dependencies, recursively. /// - Recursive, + Recursive = 1, /// /// Discover only direct dependencies. @@ -20,5 +21,11 @@ public enum ResourceDependencyDiscoveryMode /// and from environment variables and command-line arguments, but does not recurse /// into the dependencies of those dependencies. /// - DirectOnly + DirectOnly = 2, + + /// + /// When set, unresolved values from annotation callbacks will be cached and reused + /// on subsequent evaluations of the same annotation, rather than re-evaluating the callback each time. + /// + CacheAnnotationCallbackResults = 4 } diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs index 1296b4d104e..a1f102d0984 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs @@ -1256,7 +1256,7 @@ internal static ILogger GetLogger(this IResource resource, IServiceProvider serv /// /// The resource to compute dependencies for. /// The execution context for resolving environment variables and arguments. - /// Specifies whether to discover only direct dependencies or the full transitive closure. + /// Specifies dependency discovery mode. /// A cancellation token to observe while computing dependencies. /// A set of all resources that the specified resource depends on. /// @@ -1292,7 +1292,7 @@ public static Task> GetResourceDependenciesAsync( /// /// The source set of resources to compute dependencies for. /// The execution context for resolving environment variables and arguments. - /// Specifies whether to discover only direct dependencies or the full transitive closure. + /// Specifies dependency discovery mode. /// A cancellation token to observe while computing dependencies. /// A set of all resources that the specified resource depends on. /// @@ -1320,6 +1320,12 @@ internal static async Task> GetDependenciesAsync( ResourceDependencyDiscoveryMode mode = ResourceDependencyDiscoveryMode.Recursive, CancellationToken cancellationToken = default) { + var modeOK = mode.HasFlag(ResourceDependencyDiscoveryMode.Recursive) ^ mode.HasFlag(ResourceDependencyDiscoveryMode.DirectOnly); + if (!modeOK) + { + throw new ArgumentException($"Exactly one of {nameof(ResourceDependencyDiscoveryMode.Recursive)} or {nameof(ResourceDependencyDiscoveryMode.DirectOnly)} must be set in the mode parameter.", nameof(mode)); + } + var dependencies = new HashSet(); var newDependencies = new HashSet(); var toProcess = new Queue(); @@ -1327,13 +1333,13 @@ internal static async Task> GetDependenciesAsync( foreach (var resource in resources) { newDependencies.Clear(); - await GatherDirectDependenciesAsync(resource, dependencies, newDependencies, executionContext, cancellationToken).ConfigureAwait(false); + await GatherDirectDependenciesAsync(resource, dependencies, newDependencies, executionContext, mode, cancellationToken).ConfigureAwait(false); - if (mode == ResourceDependencyDiscoveryMode.Recursive) + if (mode.HasFlag(ResourceDependencyDiscoveryMode.Recursive)) { // Compute transitive closure by recursively processing dependencies - foreach(var nd in newDependencies) + foreach (var nd in newDependencies) { toProcess.Enqueue(nd); } @@ -1343,7 +1349,7 @@ internal static async Task> GetDependenciesAsync( var dep = toProcess.Dequeue(); newDependencies.Clear(); - await GatherDirectDependenciesAsync(dep, dependencies, newDependencies, executionContext, cancellationToken).ConfigureAwait(false); + await GatherDirectDependenciesAsync(dep, dependencies, newDependencies, executionContext, mode, cancellationToken).ConfigureAwait(false); foreach (var newDep in newDependencies) { @@ -1372,12 +1378,14 @@ internal static async Task> GetDependenciesAsync( /// The set of dependencies (where dependency resources will be placed). /// The set of newly discovered dependencies in this invocation (not present in at the moment of invocation). /// The execution context for resolving environment variables and arguments. + /// Specifies dependency discovery mode. /// A cancellation token to observe while gathering dependencies. private static async Task GatherDirectDependenciesAsync( IResource resource, HashSet dependencies, HashSet newDependencies, DistributedApplicationExecutionContext executionContext, + ResourceDependencyDiscoveryMode mode, CancellationToken cancellationToken) { var visited = new HashSet(); @@ -1386,7 +1394,7 @@ private static async Task GatherDirectDependenciesAsync( CollectAnnotationDependencies(resource, dependencies, newDependencies); // Collect raw (unresolved) environment variable and argument values - var rawValues = await GatherRawEnvironmentAndArgumentValuesAsync(resource, executionContext, cancellationToken).ConfigureAwait(false); + var rawValues = await GatherRawEnvironmentAndArgumentValuesAsync(resource, executionContext, mode, cancellationToken).ConfigureAwait(false); foreach (var value in rawValues) { @@ -1400,26 +1408,38 @@ private static async Task GatherDirectDependenciesAsync( private static async Task> GatherRawEnvironmentAndArgumentValuesAsync( IResource resource, DistributedApplicationExecutionContext executionContext, + ResourceDependencyDiscoveryMode mode, CancellationToken cancellationToken) { var rawValues = new List(); // Gather environment variable values - if (resource.TryGetEnvironmentVariables(out var environmentCallbacks)) + if (resource.TryGetEnvironmentVariables(out var envAnnotations)) { var envVars = new Dictionary(); - var context = new EnvironmentCallbackContext(executionContext, resource, envVars, cancellationToken); - - foreach (var callback in environmentCallbacks) + var context = new EnvironmentCallbackContext(executionContext, resource, envVars, cancellationToken: cancellationToken); + + if (mode.HasFlag(ResourceDependencyDiscoveryMode.CacheAnnotationCallbackResults)) { - await callback.Callback(context).ConfigureAwait(false); + foreach (var ann in envAnnotations) + { + var resultingVars = await ann.AsCallbackAnnotation().EvaluateOnceAsync(context).ConfigureAwait(false); + rawValues.AddRange(resultingVars.Values); + } + + } + else + { + foreach (var ann in envAnnotations) + { + await ann.Callback(context).ConfigureAwait(false); + } + rawValues.AddRange(envVars.Values); } - - rawValues.AddRange(envVars.Values); } // Gather command-line argument values - if (resource.TryGetAnnotationsOfType(out var argsCallbacks)) + if (resource.TryGetAnnotationsOfType(out var argAnnotations)) { var args = new List(); var context = new CommandLineArgsCallbackContext(args, resource, cancellationToken) @@ -1427,12 +1447,22 @@ private static async Task> GatherRawEnvironmentAndArgumentValuesAsy ExecutionContext = executionContext }; - foreach (var callback in argsCallbacks) + if (mode.HasFlag(ResourceDependencyDiscoveryMode.CacheAnnotationCallbackResults)) { - await callback.Callback(context).ConfigureAwait(false); + foreach (var ann in argAnnotations) + { + var resultingArgs = await ann.AsCallbackAnnotation().EvaluateOnceAsync(context).ConfigureAwait(false); + rawValues.AddRange(resultingArgs); + } + } + else + { + foreach (var ann in argAnnotations) + { + await ann.Callback(context).ConfigureAwait(false); + } + rawValues.AddRange(args); } - - rawValues.AddRange(args); } return rawValues; diff --git a/src/Aspire.Hosting/Dcp/ContainerCreationContext.cs b/src/Aspire.Hosting/Dcp/ContainerCreationContext.cs new file mode 100644 index 00000000000..3c371fd7b61 --- /dev/null +++ b/src/Aspire.Hosting/Dcp/ContainerCreationContext.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Channels; +using Aspire.Hosting.Dcp.Model; + +namespace Aspire.Hosting.Dcp; + +/// +/// A ContainerNetworkService represents a service implemented by a host resource but exposed on a container network. +/// +internal record class ContainerNetworkService +{ + public required ServiceAppResource ServiceResource { get; init; } + public TunnelConfiguration? TunnelConfig { get; init; } +} + +/// +/// Helps coordinate container creation tasks and container tunnel creation and configuration task. +/// +internal sealed class ContainerCreationContext : IDisposable +{ + public readonly CountdownEvent ContainerServicesSpecReady; + public readonly Channel ContainerServicesChan; + private readonly Lazy _createTunnelLazy; + + public Task CreateTunnel => _createTunnelLazy.Value; + + public ContainerCreationContext(int containerCount, Func createTunnelFunc) + { + ContainerServicesSpecReady = new CountdownEvent(containerCount); + ContainerServicesChan = Channel.CreateUnbounded(); + _createTunnelLazy = new Lazy(() => createTunnelFunc(this), LazyThreadSafetyMode.ExecutionAndPublication); + } + + /// + /// Disposes resources owned by the container creation context. + /// + public void Dispose() + { + ContainerServicesSpecReady.Dispose(); + } +} diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 1504bb97060..c629cc21c6e 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -73,11 +73,17 @@ internal sealed partial class DcpExecutor : IDcpExecutor, IConsoleLogsService, I private readonly IDistributedApplicationEventing _distributedApplicationEventing; private readonly IOptions _options; private readonly DistributedApplicationExecutionContext _executionContext; - private readonly List _appResources = []; + private readonly ConcurrentBag _appResources = []; + + // Has an entry if we raised ResourceEndpointsAllocatedEvent for a resource with a given name. + // We want to ensure we raise the event only once for each app model resource. + // There may be multiple physical replicas of the same app model resource + // which can result in the event being raised multiple times if we are not careful. + private readonly HashSet _endpointsAdvertised = new(StringComparers.ResourceName); + private readonly CancellationTokenSource _shutdownCancellation = new(); private readonly DcpExecutorEvents _executorEvents; private readonly Locations _locations; - private readonly IDeveloperCertificateService _developerCertificateService; private readonly DcpResourceState _resourceState; private readonly ResourceSnapshotBuilder _snapshotBuilder; private readonly SemaphoreSlim _serverCertificateCacheSemaphore = new(1, 1); @@ -111,8 +117,7 @@ public DcpExecutor(ILogger logger, IDcpDependencyCheckService dcpDependencyCheckService, DcpNameGenerator nameGenerator, DcpExecutorEvents executorEvents, - Locations locations, - IDeveloperCertificateService developerCertificateService) + Locations locations) { _distributedApplicationLogger = distributedApplicationLogger; _kubernetesService = kubernetesService; @@ -131,7 +136,6 @@ public DcpExecutor(ILogger logger, _snapshotBuilder = new(_resourceState); _normalizedApplicationName = NormalizeApplicationName(hostEnvironment.ApplicationName); _locations = locations; - _developerCertificateService = developerCertificateService; DeleteResourceRetryPipeline = DcpPipelineBuilder.BuildDeleteRetryPipeline(logger); WatchResourceRetryPipeline = DcpPipelineBuilder.BuildWatchResourcePipeline(logger); @@ -163,7 +167,7 @@ public async Task RunApplicationAsync(CancellationToken ct = default) AspireEventSource.Instance.DcpServiceObjectPreparationStart(); try { - await PrepareServicesAsync(ct).ConfigureAwait(false); + PrepareServices(); } finally { @@ -177,10 +181,6 @@ public async Task RunApplicationAsync(CancellationToken ct = default) WatchResourceChanges(); - // Ensure we fire the event only once for each app model resource. There may be multiple physical replicas of - // the same app model resource which can result in the event being fired multiple times. - HashSet endpointsAdvertised = new(StringComparers.ResourceName); - var createServices = Task.Run(() => CreateAllDcpObjectsAsync(ct), ct); var getProxyAddresses = Task.Run(async () => @@ -195,15 +195,15 @@ public async Task RunApplicationAsync(CancellationToken ct = default) var createContainerNetworks = Task.Run(() => CreateAllDcpObjectsAsync(ct), ct); - var executables = _appResources.OfType().Where(ar => ar.DcpResource is Executable); - var (regular, tunnelDependent, regularContainerExes, tunnelDependentContainerExes) = await GetContainerCreationSetsAsync(ct).ConfigureAwait(false); + var executables = _appResources.OfType().Where(ar => ar.DcpResource is Executable).ToArray(); + var containers = _appResources.OfType().Where(ar => ar.DcpResource is Container).ToArray(); var createExecutableEndpoints = Task.Run(async () => { await getProxyAddresses.ConfigureAwait(false); AddAllocatedEndpointInfo(executables, AllocatedEndpointsMode.Workload); - await PublishEndpointAllocatedEventAsync(endpointsAdvertised, executables, ct).ConfigureAwait(false); + await PublishEndpointAllocatedEventAsync(executables, ct).ConfigureAwait(false); }, ct); var createExecutables = Task.Run(async () => @@ -213,62 +213,51 @@ public async Task RunApplicationAsync(CancellationToken ct = default) await CreateExecutablesAsync(executables, ct).ConfigureAwait(false); }, ct); - var createRegularContainers = Task.Run(async () => + Task createTunnelFunc(ContainerCreationContext cctx) => Task.Run(async () => { - await Task.WhenAll([getProxyAddresses, createContainerNetworks]).ConfigureAwait(false); + await Task.WhenAll([getProxyAddresses, createContainerNetworks]).WaitAsync(ct).ConfigureAwait(false); - AddAllocatedEndpointInfo(regular, AllocatedEndpointsMode.Workload); - await PublishEndpointAllocatedEventAsync(endpointsAdvertised, regular, ct).ConfigureAwait(false); + // Container creation tasks need to figure out dependencies of each container + // and then create Service and TunnelConfiguration definitions for each of them. + cctx.ContainerServicesSpecReady.Wait(ct); + cctx.ContainerServicesChan.Writer.Complete(); - await Task.WhenAll( - CreateContainersAsync(regular, ct), - CreateContainerExecutablesAsync(regularContainerExes, ct) - ).WaitAsync(ct).ConfigureAwait(false); - }, ct); - - var createTunnel = Task.Run(async () => - { - if (!tunnelDependent.Any()) - { - return; // No tunnel-dependent containers, nothing to do. - } - - await Task.WhenAll([getProxyAddresses, createContainerNetworks]).ConfigureAwait(false); + // Now create the container network services for the host resources, update the tunnel, and advertise AllocatedEndpoints. + var containerNetworkServices = cctx.ContainerServicesChan.Reader.ReadAllAsync(ct).ToBlockingEnumerable(ct).ToArray(); + _appResources.AddRange(containerNetworkServices.Select(cns => cns.ServiceResource)); + var serviceObjects = containerNetworkServices.Select(cns => cns.ServiceResource.Service).ToArray(); + await CreateDcpObjectsAsync(serviceObjects, ct).ConfigureAwait(false); + var tunnels = containerNetworkServices.Where(s => s.TunnelConfig is not null).Select(s => s.TunnelConfig!).ToList(); + Debug.Assert(tunnels.Count == containerNetworkServices.Length, "Each tunneled service should have a tunnel config"); + var tunnelAppResource = CreateTunnelProxyResource(tunnels); + var tunnelProxy = (ContainerNetworkTunnelProxy)tunnelAppResource.DcpResource; await CreateAllDcpObjectsAsync(ct).ConfigureAwait(false); - await EnsureContainerServiceAddressInfo(ct).ConfigureAwait(false); - AddAllocatedEndpointInfo(executables, AllocatedEndpointsMode.ContainerTunnel); - }, ct); + // Container tunnel initialization can take a while if the container tunnel image needs to be built, + // especially if the required image pull is slow, hence 10 minute timeout here. + await UpdateWithEffectiveAddressInfo(serviceObjects, ct, TimeSpan.FromMinutes(10)).ConfigureAwait(false); - var createTunnelDependentContainers = Task.Run(async () => - { - if (!tunnelDependent.Any()) - { - return; // No tunnel-dependent containers, nothing to do. - } + AddAllocatedEndpointInfo(executables, AllocatedEndpointsMode.ContainerTunnel); - await Task.WhenAll([getProxyAddresses, createContainerNetworks, createExecutableEndpoints]).ConfigureAwait(false); + // createExecutableEndpoints() is not really part of container tunnel initialization, + // but configuring containers that use the tunnel require these host network-side endpoints to be ready, + // so instead of having container creation tasks wait on two separate tasks (current one + createExecutableEndpoints), + // we just wait for createExecutableEndpoints here, and container creation tasks can then wait on this one. + await createExecutableEndpoints.ConfigureAwait(false); + }, ct); - // There is no need to wait with creating tunnel-dependent containers till container tunnel is created. - // The containers will not be started until the tunnel endpoints they use are ready, but this is handled internally by DCP. + using var cctx = new ContainerCreationContext(containers.Length, createTunnelFunc); - AddAllocatedEndpointInfo(tunnelDependent, AllocatedEndpointsMode.Workload); - await PublishEndpointAllocatedEventAsync(endpointsAdvertised, tunnelDependent, ct).ConfigureAwait(false); + var createContainers = Task.Run(async () => + { + await Task.WhenAll([getProxyAddresses, createContainerNetworks]).WaitAsync(ct).ConfigureAwait(false); - await Task.WhenAll( - CreateContainersAsync(tunnelDependent, ct), - CreateContainerExecutablesAsync(tunnelDependentContainerExes, ct) - ).WaitAsync(ct).ConfigureAwait(false); + await Task.WhenAll(containers.Select(c => Task.Run(() => CreateSingleContainerAsync(c, cctx, ct)))).WaitAsync(ct).ConfigureAwait(false); }, ct); - // Now wait for all creations to complete. - await Task.WhenAll( - createTunnel, - createExecutables, - createRegularContainers, - createTunnelDependentContainers - ).WaitAsync(ct).ConfigureAwait(false); + // Now wait for all "leaf" creations to complete. + await Task.WhenAll(createExecutables, createContainers).WaitAsync(ct).ConfigureAwait(false); await _executorEvents.PublishAsync(new OnEndpointsAllocatedContext(ct)).ConfigureAwait(false); } @@ -908,44 +897,16 @@ await createServicePipeline.ExecuteAsync(async (attemptCancellationToken) => AspireEventSource.Instance.DcpServiceAddressAllocationFailed(sar.Metadata.Name); } } - } - finally - { - AspireEventSource.Instance.DcpServiceAddressAllocationStop(initialServiceCount - needAddressAllocated.Count); - } - } - // Ensures that services used by containers have their address info. - private async Task EnsureContainerServiceAddressInfo(CancellationToken cancellationToken) - { - if (_options.Value.EnableAspireContainerTunnel) - { - var containerTunnelProxies = _appResources.Where(r => r.DcpResource is ContainerNetworkTunnelProxy { }).ToImmutableArray(); - foreach (var ctp in containerTunnelProxies) + if (_options.Value.EnableAspireContainerTunnel) { - var containerNetworkName = (ctp.DcpResource as ContainerNetworkTunnelProxy)?.Spec.ContainerNetworkName; - - // Need to wait for all tunnels to start before advertising AllocatedEndpoints that the tunnel proxy projected - // from host network into container network(s). - var tunnelServices = _appResources.Where(r => r.DcpResource is Service { }).Select(r => (Service)r.DcpResource) - .Where( - sr => !sr.HasCompleteAddress && - sr.Metadata.Annotations?.TryGetValue(CustomResource.ContainerTunnelInstanceName, out var _) is true && - sr.Metadata.Annotations?.TryGetValue(CustomResource.ContainerNetworkAnnotation, out var containerNetwork) is true && - containerNetwork == containerNetworkName - ); - - _logger.LogInformation($"Waiting for container network '{containerNetworkName}' tunnel initialization..."); - // Container tunnel initialization can take a while if the container tunnel image needs to be built, - // expecially if the network is slow, hence 10 minute timeout here. - await UpdateWithEffectiveAddressInfo(tunnelServices, cancellationToken, TimeSpan.FromMinutes(10)).ConfigureAwait(false); - _logger.LogInformation($"Tunnel for container network '{containerNetworkName}' initialized"); + // Tunnel endpoints will be enabled (and get their endpoints) on as-needed basis. We are done for now. + return; } - } - else - { + // Container services are services that "mirror" their primary (host) service counterparts, but expose addresses usable from container network. - // We just need to update their ports from primary services, changing the address to container host. + // Without the tunnel we rely on Docker Desktop host.docker.internal bridge, + // which means we just need to update their ports from primary services, changing the address to container host. var containerServices = _appResources.Where(r => r.DcpResource is Service { }).Select(r => ( Service: r.DcpResource as Service, PrimaryServiceName: r.DcpResource.Metadata.Annotations?.TryGetValue(CustomResource.PrimaryServiceNameAnnotation, out var psn) == true ? psn : null) @@ -955,16 +916,26 @@ private async Task EnsureContainerServiceAddressInfo(CancellationToken cancellat foreach (var cs in containerServices) { var primaryService = _appResources.OfType().Select(sar => sar.Service) - .Where(svc => svc.Metadata.Name.Equals(cs.PrimaryServiceName)).First(); + .First(svc => svc.Metadata.Name.Equals(cs.PrimaryServiceName)); cs.Service!.ApplyAddressInfoFrom(primaryService); cs.Service!.Status!.EffectiveAddress = ContainerHostName; } } + finally + { + AspireEventSource.Instance.DcpServiceAddressAllocationStop(initialServiceCount - needAddressAllocated.Count); + } } - private async Task CreateAllDcpObjectsAsync(CancellationToken cancellationToken) where RT : CustomResource, IKubernetesStaticMetadata + private Task CreateAllDcpObjectsAsync(CancellationToken cancellationToken) where RT : CustomResource, IKubernetesStaticMetadata { - var toCreate = _appResources.Select(r => r.DcpResource).OfType().ToImmutableArray(); + var objects = _appResources.Select(r => r.DcpResource).OfType(); + return CreateDcpObjectsAsync(objects, cancellationToken); + } + + private async Task CreateDcpObjectsAsync(IEnumerable objects, CancellationToken cancellationToken) where RT : CustomResource, IKubernetesStaticMetadata + { + var toCreate = objects.ToImmutableArray(); if (toCreate.Length == 0) { return; @@ -1177,7 +1148,7 @@ private void PrepareContainerNetworks() /// /// Creates DCP Service objects that represent services exposed by resources in the model via endpoints (EndpointAnnotations). /// - private async Task PrepareServicesAsync(CancellationToken cancellationToken) + private void PrepareServices() { _logger.LogDebug("Preparing services. Ports randomized: {RandomizePorts}", _options.Value.RandomizePorts); @@ -1185,17 +1156,19 @@ private async Task PrepareServicesAsync(CancellationToken cancellationToken) .Select(r => (ModelResource: r, Endpoints: r.Annotations.OfType().ToArray())) .Where(sp => sp.Endpoints.Any()); - // We need to ensure that Services have unique names (otherwise we cannot really distinguish between - // services produced by different resources). - var serviceNames = new HashSet(); - foreach (var sp in serviceProducers) { var endpoints = sp.Endpoints; foreach (var endpoint in endpoints) { - var serviceName = _nameGenerator.GetServiceName(sp.ModelResource, endpoint, endpoints.Length > 1, serviceNames); + var (serviceName, isNew) = _nameGenerator.GetServiceName(sp.ModelResource, endpoint, endpoint.DefaultNetworkID); + if (!isNew) + { + _logger.LogWarning("Encountered the same service-endpoint combination more than once for {EndpointName} on resource {ResourceName} when creating default endpoint services. This should never happen.", endpoint.Name, sp.ModelResource.Name); + continue; + } + var svc = Service.Create(serviceName); if (!sp.ModelResource.SupportsProxy()) @@ -1239,129 +1212,93 @@ private async Task PrepareServicesAsync(CancellationToken cancellationToken) } } - // For container-to-host communication we create a tunnel proxy with a Service/tunnel for each host Endpoint. - var containers = _model.Resources.Where(r => r.IsContainer()); if (!containers.Any()) { - return; // No container resources--no need to set up container-to-host tunnels. - } - - var containerDependencies = await ResourceExtensions.GetDependenciesAsync(containers, _executionContext, ResourceDependencyDiscoveryMode.DirectOnly, cancellationToken).ConfigureAwait(false); - - // Host dependencies are host network resources with endpoints that containers depend on. - List hostDependencies = containerDependencies.Select(AsHostResourceWithEndpoints).OfType().ToList(); - - // Aspire dashboard is special in the context of Open Telemetry ingestion. - // OTLP exporters do not refer to the OTLP ingestion endpoint via EndpointReference when the model is constructed - // by the Aspire app host; the endpoint URL is just read from configuration. - // If there are containers that are OTLP exporters in the model, we need to project dashboard endpoints into container space. - if (containers.Any(c => c.TryGetAnnotationsOfType(out _))) - { - var maybeDashboard = _model.Resources.Where(r => StringComparers.ResourceName.Equals(r.Name, KnownResourceNames.AspireDashboard)) - .Select(AsHostResourceWithEndpoints).FirstOrDefault(); - if (maybeDashboard is HostResourceWithEndpoints dashboardResource) - { - hostDependencies.Add(dashboardResource); - } + return; // No container resources--no need bother with container-to-host connections. } - if (!hostDependencies.Any()) + if (_options.Value.EnableAspireContainerTunnel) { - // There are no containers that reference host resource endpoints, so no need for container tunnel. + // Tunnel services and tunnel configuration is set up together with containers, dynamically. return; } - // Eventually we might want to support multiple container networks, including user-defined ones, - // but for now we just have one container network per application, and so we need only one tunnel proxy. - ContainerNetworkTunnelProxy? tunnelProxy = null; - AppResource? tunnelAppResource = null; - var useTunnel = _options.Value.EnableAspireContainerTunnel; - if (useTunnel) + // Legacy (no tunnel) mode: we are going to just proxy all host endpoint into the container network. + var hostResources = _model.Resources.Select(AsHostResourceWithEndpoints).OfType().ToList(); + + foreach (var re in hostResources) { - tunnelProxy = ContainerNetworkTunnelProxy.Create(KnownNetworkIdentifiers.DefaultAspireContainerNetwork.Value + "-tunnelproxy"); - tunnelProxy.Spec.ContainerNetworkName = KnownNetworkIdentifiers.DefaultAspireContainerNetwork.Value; - tunnelProxy.Spec.Aliases = [ContainerHostName]; - tunnelProxy.Spec.Tunnels = []; - tunnelAppResource = new AppResource(tunnelProxy); - _appResources.Add(tunnelAppResource); + var containerNetworkServices = CreateContainerNetworkServicesForHostResource(re); + _appResources.AddRange(containerNetworkServices.Select(cns => cns.ServiceResource)); } + } - // If multiple Containers take a reference to the same host resource, we should only create one Service per each endpoint. - HashSet<(string HostResourceName, string OriginalEndpointName)> processedEndpoints = new(); + private IEnumerable CreateContainerNetworkServicesForHostResource(HostResourceWithEndpoints re) + { + var resourceLogger = _loggerService.GetLogger(re.Resource); + var services = new List(); + var useTunnel = _options.Value.EnableAspireContainerTunnel; + string tunnelProxyName = useTunnel ? GetTunnelProxyResourceName() : ""; - foreach (var re in hostDependencies) + foreach (var endpoint in re.Endpoints) { - var resourceLogger = _loggerService.GetLogger(re.Resource); - - foreach (var endpoint in re.Endpoints) + var (serviceName, isNew) = _nameGenerator.GetServiceName(re.Resource, endpoint, KnownNetworkIdentifiers.DefaultAspireContainerNetwork); + if (!isNew) { - if (!processedEndpoints.Add((re.Resource.Name, endpoint.Name))) - { - continue; // Already processed this endpoint reference. - } + // Entirely possible that multiple container resources reference the same host resource and endpoint. + // We let the first container creation task (exists == false) create the service and other tasks just leverages it. + continue; + } - if (useTunnel) - { - if (endpoint.Protocol != ProtocolType.Tcp) - { - resourceLogger.LogWarning("Host endpoint '{EndpointName}' on resource '{HostResource}' is referenced by a container resource, but the endpoint is using a network protocol '{Protocol}' other than TCP. Only TCP is supported for container-to-host references.", - endpoint.Name, - re.Resource.Name, - endpoint.Protocol); - continue; - } - } + if (useTunnel && endpoint.Protocol != ProtocolType.Tcp) + { + resourceLogger.LogWarning("Host endpoint '{EndpointName}' on resource '{HostResource}' is referenced by a container resource, but the endpoint is using a network protocol '{Protocol}' other than TCP. Only TCP is supported for container-to-host references.", endpoint.Name, re.Resource.Name, endpoint.Protocol); + continue; + } - var hasManyEndpoints = re.Resource.Annotations.OfType().Count() > 1; - var serviceName = _nameGenerator.GetServiceName(re.Resource, endpoint, hasManyEndpoints, serviceNames); - var svc = Service.Create(serviceName); - svc.Spec.AddressAllocationMode = AddressAllocationModes.Proxyless; - svc.Spec.Protocol = PortProtocol.TCP; - // Address and port will be set automatically by DCP. + var svc = Service.Create(serviceName); + svc.Spec.AddressAllocationMode = AddressAllocationModes.Proxyless; + svc.Spec.Protocol = PortProtocol.FromProtocolType(endpoint.Protocol); + // Address and port will be set automatically by DCP. - var serverSvc = _appResources.OfType().FirstOrDefault(swr => - string.Equals(swr.ModelResource.Name, re.Resource.Name, StringComparisons.ResourceName) && - string.Equals(swr.EndpointAnnotation.Name, endpoint.Name, StringComparisons.EndpointAnnotationName) - ); - if (serverSvc is null) - { - // This should never happen--if a host resource has an Endpoint, we should have created a Service for it. - throw new InvalidDataException($"Host endpoint '{endpoint.Name}' on resource '{re.Resource.Name}' should have an associated DCP Service resource already set up"); - } + var serverSvc = _appResources.OfType().FirstOrDefault(swr => + StringComparers.ResourceName.Equals(swr.ModelResource.Name, re.Resource.Name) && + StringComparers.EndpointAnnotationName.Equals(swr.EndpointAnnotation.Name, endpoint.Name) + ); + if (serverSvc is null) + { + // This should never happen--if a host resource has an Endpoint, we should have created a Service for it. + throw new InvalidDataException($"Host endpoint '{endpoint.Name}' on resource '{re.Resource.Name}' should have an associated DCP Service resource already set up"); + } - if (useTunnel) + TunnelConfiguration? tunnelConfig = null; + if (useTunnel) + { + tunnelConfig = new TunnelConfiguration { - var tunnelConfig = new TunnelConfiguration - { - Name = serviceName, - ServerServiceName = serverSvc.DcpResource.Metadata.Name, - ServerServiceNamespace = string.Empty, - ClientServiceName = svc.Metadata.Name, - ClientServiceNamespace = string.Empty - }; - - // The tunnelProxy is guaranteed to be non-null here but the compiler is not smart enough to realize it. - tunnelProxy?.Spec?.Tunnels?.Add(tunnelConfig); - } - - svc.Annotate(CustomResource.ResourceNameAnnotation, re.Resource.Name); // Resource that implements the service behind the Endpoint. - svc.Annotate(CustomResource.EndpointNameAnnotation, endpoint.Name); - svc.Annotate(CustomResource.ContainerNetworkAnnotation, tunnelProxy?.Spec?.ContainerNetworkName ?? KnownNetworkIdentifiers.DefaultAspireContainerNetwork.Value); - svc.Annotate(CustomResource.PrimaryServiceNameAnnotation, serverSvc.DcpResource.Metadata.Name); + Name = serviceName, + ServerServiceName = serverSvc.DcpResource.Metadata.Name, + ServerServiceNamespace = string.Empty, + ClientServiceName = svc.Metadata.Name, + ClientServiceNamespace = string.Empty + }; + } - // We use this to distinguish services based on real tunnel proxies vs "placeholders" for when tunnels are disabled. - svc.Annotate(CustomResource.ContainerTunnelInstanceName, tunnelProxy?.Metadata?.Name ?? ""); + svc.Annotate(CustomResource.ResourceNameAnnotation, re.Resource.Name); // Resource that implements the service behind the Endpoint. + svc.Annotate(CustomResource.EndpointNameAnnotation, endpoint.Name); + svc.Annotate(CustomResource.ContainerNetworkAnnotation, KnownNetworkIdentifiers.DefaultAspireContainerNetwork.Value); + svc.Annotate(CustomResource.PrimaryServiceNameAnnotation, serverSvc.DcpResource.Metadata.Name); - var svcAppResource = new ServiceAppResource(svc); - _appResources.Add(svcAppResource); + // We use this to distinguish services based on real tunnel proxies + // vs "placeholders" relying on host.docker.internal bridge (when tunnel is disabled). + svc.Annotate(CustomResource.ContainerTunnelInstanceName, tunnelProxyName); - if (useTunnel) - { - tunnelAppResource!.ServicesProduced.Add(svcAppResource); - } - } + var svcAppResource = new ServiceAppResource(svc); + services.Add(new ContainerNetworkService { ServiceResource = svcAppResource, TunnelConfig = tunnelConfig }); } + + return services; } private void PrepareExecutables() @@ -1658,6 +1595,7 @@ await _executorEvents.PublishAsync(new OnResourceChangedContext( return; } + await _executorEvents.PublishAsync(new OnConnectionStringAvailableContext(cancellationToken, resource)).ConfigureAwait(false); await _executorEvents.PublishAsync(new OnResourceStartingContext(cancellationToken, resourceType, resource, DcpResourceName: null)).ConfigureAwait(false); foreach (var er in executables) { @@ -2050,316 +1988,352 @@ private static DcpInstance GetDcpInstance(IResource resource, int instanceIndex) throw new DistributedApplicationException($"Couldn't find required instance ID for index {instanceIndex} on resource {resource.Name}."); } - private async Task CreateContainersAsync(IEnumerable containerResources, CancellationToken cancellationToken) + private async Task CreateSingleContainerAsync(RenderedModelResource cr, ContainerCreationContext cctx, CancellationToken cToken) { - var containerCount = containerResources.Count(); - if (containerCount == 0) - { - return; - } + var dcpContainer = (Container)cr.DcpResource; + AspireEventSource.Instance.DcpObjectCreationStart(dcpContainer.Kind, dcpContainer.Metadata.Name); + var signalServicesSpecReadyOnce = ConcurrencyUtils.Once(() => cctx.ContainerServicesSpecReady.Signal()); try { - AspireEventSource.Instance.DcpObjectSetCreationStart(Model.Dcp.ContainerKind, containerCount); + cToken.ThrowIfCancellationRequested(); + var logger = _loggerService.GetLogger(cr.ModelResource); + AddAllocatedEndpointInfo([cr], AllocatedEndpointsMode.Workload); - async Task CreateContainerAsyncCore(RenderedModelResource cr, CancellationToken cancellationToken) + try { - var logger = _loggerService.GetLogger(cr.ModelResource); + // In previous versions of Aspire we would delay raising BeforeResourceStarted event for explicit startup resources. + // But we need to determine whether the container is using any host resources, + // and need to call environment and argument annotation callbacks in the process, and this means + // we need to raise this event now, so that "last chance dynamic setup or validation" can be performed for the resource + // (see docs/specs/appmodel.md#well-known-lifecycle-events) + await _executorEvents.PublishAsync(new OnResourceStartingContext(cToken, KnownResourceTypes.Container, cr.ModelResource, dcpContainer.Metadata.Name)).ConfigureAwait(false); - try + // Publish snapshot built from DCP resource. Do this now to populate more values from DCP (source) to ensure they're + // available if the resource isn't immediately started because it's waiting or is configured for explicit start. + await _executorEvents.PublishAsync(new OnResourceChangedContext(_shutdownCancellation.Token, KnownResourceTypes.Container, cr.ModelResource, cr.DcpResourceName, new ResourceStatus(null, null, null), s => _snapshotBuilder.ToSnapshot((Container)cr.DcpResource, s))).ConfigureAwait(false); + + // Note: resource restart is done by calling StartResourceAsync(), which sets Spec.Start to true and + // calls CreateDcpContainerAsync() directly, bypassing the following check. + var explicitStartup = cr.ModelResource.TryGetLastAnnotation(out _); + if (explicitStartup) { - await CreateContainerAsync(cr, logger, cancellationToken).ConfigureAwait(false); + dcpContainer.Spec.Start = false; } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // Expected cancellation during shutdown - propagate clean cancellation - throw; - } - catch (FailedToApplyEnvironmentException) + + var hostDependencies = (await GetHostDependenciesAsync(cr.ModelResource, cToken).ConfigureAwait(false)).ToImmutableArray(); + + if (hostDependencies.Any()) { - // For this exception we don't want the noise of the stack trace, we've already - // provided more detail where we detected the issue (e.g. envvar name). To get - // more diagnostic information reduce logging level for DCP log category to Debug. - await _executorEvents.PublishAsync(new OnResourceFailedToStartContext(cancellationToken, KnownResourceTypes.Container, cr.ModelResource, cr.DcpResourceName)).ConfigureAwait(false); + await CreateTunnelDependentContainerAsync(cr, hostDependencies, cctx, signalServicesSpecReadyOnce, cToken).ConfigureAwait(false); } - catch (Exception ex) + else { - logger.LogError(ex, "Failed to create container resource {ResourceName}", cr.ModelResource.Name); - await _executorEvents.PublishAsync(new OnResourceFailedToStartContext(cancellationToken, KnownResourceTypes.Container, cr.ModelResource, cr.DcpResourceName)).ConfigureAwait(false); + // There will be no tunnel services for this container; we have complete information about services this container will need. + signalServicesSpecReadyOnce(); + + await CreateDcpContainerAsync(cr, logger, cToken).ConfigureAwait(false); } } - - var tasks = new List(); - - foreach (var cr in containerResources) + catch (OperationCanceledException) when (cToken.IsCancellationRequested) { - // Publish snapshot built from DCP resource. Do this now to populate more values from DCP (source) to ensure they're - // available if the resource isn't immediately started because it's waiting or is configured for explicit start. - await _executorEvents.PublishAsync(new OnResourceChangedContext(_shutdownCancellation.Token, KnownResourceTypes.Container, cr.ModelResource, cr.DcpResourceName, new ResourceStatus(null, null, null), s => _snapshotBuilder.ToSnapshot((Container)cr.DcpResource, s))).ConfigureAwait(false); - - if (cr.ModelResource.TryGetLastAnnotation(out _)) - { - if (cr.DcpResource is Container container) - { - container.Spec.Start = false; - } - } - - // Force this to be async so that blocking code does not stop other containers from being created. - tasks.Add(Task.Run(() => CreateContainerAsyncCore(cr, cancellationToken), cancellationToken)); + // Expected cancellation during shutdown - propagate clean cancellation + throw; + } + catch (FailedToApplyEnvironmentException) + { + // For this exception we don't want the noise of the stack trace, we've already + // provided more detail where we detected the issue (e.g. envvar name). To get + // more diagnostic information reduce logging level for DCP log category to Debug. + await _executorEvents.PublishAsync(new OnResourceFailedToStartContext(cToken, KnownResourceTypes.Container, cr.ModelResource, cr.DcpResourceName)).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create container resource {ResourceName}", cr.ModelResource.Name); + await _executorEvents.PublishAsync(new OnResourceFailedToStartContext(cToken, KnownResourceTypes.Container, cr.ModelResource, cr.DcpResourceName)).ConfigureAwait(false); } - - await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false); } finally { - AspireEventSource.Instance.DcpObjectSetCreationStop(Model.Dcp.ContainerKind, containerCount); + signalServicesSpecReadyOnce(); + AspireEventSource.Instance.DcpObjectCreationStop(dcpContainer.Kind, dcpContainer.Metadata.Name); } } - private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resourceLogger, CancellationToken cancellationToken) + private async Task CreateDcpContainerAsync(RenderedModelResource cr, ILogger logger, CancellationToken cToken) { - try + cToken.ThrowIfCancellationRequested(); + + await PublishEndpointAllocatedEventAsync([cr], cToken).ConfigureAwait(false); + await _executorEvents.PublishAsync(new OnConnectionStringAvailableContext(cToken, cr.ModelResource)).ConfigureAwait(false); + // BeforeResourceStarted already published by the caller. + + var dcpContainer = (Container)cr.DcpResource; + var modelContainer = cr.ModelResource; + + await ApplyBuildArgumentsAsync(dcpContainer, cr.ModelResource, _executionContext.ServiceProvider, cToken).ConfigureAwait(false); + + var spec = dcpContainer.Spec; + + if (cr.ServicesProduced.Count > 0) { - var dcpContainerResource = (Container)cr.DcpResource; - var modelContainerResource = cr.ModelResource; - AspireEventSource.Instance.DcpObjectCreationStart(dcpContainerResource.Kind, dcpContainerResource.Metadata.Name); - cancellationToken.ThrowIfCancellationRequested(); - var explicitStartup = cr.ModelResource.TryGetAnnotationsOfType(out _) is true; - if (!explicitStartup) - { - // If explicit startup is configured, we aren't going to start the resource now. A DCP resource WILL be created, - // but it will be explicitly set to not start. We don't want to send the BeforeResourceStarted event here as it will - // be sent later when the resource is explicitly started via user action or API. - await _executorEvents.PublishAsync(new OnResourceStartingContext(cancellationToken, KnownResourceTypes.Container, cr.ModelResource, cr.DcpResource.Metadata.Name)).ConfigureAwait(false); - } + spec.Ports = BuildContainerPorts(cr); + } - await ApplyBuildArgumentsAsync(dcpContainerResource, modelContainerResource, _executionContext.ServiceProvider, cancellationToken).ConfigureAwait(false); + spec.VolumeMounts = BuildContainerMounts(cr.ModelResource); - var spec = dcpContainerResource.Spec; + var (runArgs, failedToApplyRunArgs) = await BuildRunArgsAsync(logger, cr.ModelResource, cToken).ConfigureAwait(false); + if (failedToApplyRunArgs) + { + throw new FailedToApplyEnvironmentException(); + } + spec.RunArgs = runArgs; - if (cr.ServicesProduced.Count > 0) - { - spec.Ports = BuildContainerPorts(cr); - } + var (configuration, pemCertificates, createFiles) = await BuildContainerConfiguration(cr, logger, cToken).ConfigureAwait(false); + if (configuration.Exception is not null) + { + throw new FailedToApplyEnvironmentException($"Failed to apply configuration to container {cr.ModelResource.Name}", configuration.Exception); + } - spec.VolumeMounts = BuildContainerMounts(modelContainerResource); + var args = configuration.Arguments.Select(a => a.Value); + // modelContainer is not necessarily ContainerResource (can be custom resource that produces a container). + if (modelContainer is ContainerResource { ShellExecution: true }) + { + spec.Args = ["-c", $"{string.Join(' ', args)}"]; + } + else + { + spec.Args = args.ToList(); + } + dcpContainer.SetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, configuration.Arguments.Select(a => new AppLaunchArgumentAnnotation(a.Value, isSensitive: a.IsSensitive))); - (spec.RunArgs, var failedToApplyRunArgs) = await BuildRunArgsAsync(resourceLogger, modelContainerResource, cancellationToken).ConfigureAwait(false); + spec.Env = configuration.EnvironmentVariables.Select(kvp => new EnvVar { Name = kvp.Key, Value = kvp.Value }).ToList(); + spec.CreateFiles = createFiles; + if (modelContainer is ContainerResource containerResource) + { + spec.Command = containerResource.Entrypoint; + } + spec.PemCertificates = pemCertificates; - var certificatesDestination = ContainerCertificatePathsAnnotation.DefaultCustomCertificatesDestination; - var bundlePaths = ContainerCertificatePathsAnnotation.DefaultCertificateBundlePaths.ToList(); - var certificateDirsPaths = ContainerCertificatePathsAnnotation.DefaultCertificateDirectoriesPaths.ToList(); + if (_dcpInfo is not null) + { + DcpDependencyCheck.CheckDcpInfoAndLogErrors(logger, _options.Value, _dcpInfo); + } + + await _kubernetesService.CreateAsync(dcpContainer, cToken).ConfigureAwait(false); + + var containerExes = _appResources.OfType().Where(ar => ar.DcpResource is ContainerExec ce && ce.Spec.ContainerName == dcpContainer.Metadata.Name).ToArray(); + if (containerExes.Length > 0) + { + await CreateContainerExecutablesAsync(containerExes, cToken).ConfigureAwait(false); + } + } - if (cr.ModelResource.TryGetLastAnnotation(out var pathsAnnotation)) + private async Task CreateTunnelDependentContainerAsync(RenderedModelResource cr, ImmutableArray hostDependencies, ContainerCreationContext cctx, Action signalServicesSpecReadyOnce, CancellationToken cToken) + { + cToken.ThrowIfCancellationRequested(); + + // Ensure that we have services and tunnel definitions for all host dependencies of this container. + + List newServices = []; + foreach (var dep in hostDependencies) + { + var cnetServices = CreateContainerNetworkServicesForHostResource(dep); + newServices.AddRange(cnetServices); + } + if (newServices.Count > 0) + { + foreach (var s in newServices) { - certificatesDestination = pathsAnnotation.CustomCertificatesDestination ?? certificatesDestination; - bundlePaths = pathsAnnotation.DefaultCertificateBundles ?? bundlePaths; - certificateDirsPaths = pathsAnnotation.DefaultCertificateDirectories ?? certificateDirsPaths; + await cctx.ContainerServicesChan.Writer.WriteAsync(s, cToken).ConfigureAwait(false); } + } - var serverAuthCertificatesBasePath = $"{certificatesDestination}/private"; + signalServicesSpecReadyOnce(); + await cctx.CreateTunnel.ConfigureAwait(false); - var configuration = await ExecutionConfigurationBuilder.Create(cr.ModelResource) - .WithArgumentsConfig() - .WithEnvironmentVariablesConfig() - .WithCertificateTrustConfig(scope => - { - var dirs = new List { certificatesDestination + "/certs" }; - if (scope == CertificateTrustScope.Append) - { - // When appending to the default trust store, include the default certificate directories - dirs.AddRange(certificateDirsPaths!); - } + await CreateDcpContainerAsync(cr, _loggerService.GetLogger(cr.ModelResource), cToken).ConfigureAwait(false); + } - return new() - { - CertificateBundlePath = ReferenceExpression.Create($"{certificatesDestination}/cert.pem"), - // Build Linux PATH style colon-separated list of directories - CertificateDirectoriesPath = ReferenceExpression.Create($"{string.Join(':', dirs)}"), - RootCertificatesPath = certificatesDestination, - IsContainer = true, - }; - }) - .WithHttpsCertificateConfig(cert => new() - { - CertificatePath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{cert.Thumbprint}.crt"), - KeyPath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{cert.Thumbprint}.key"), - PfxPath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{cert.Thumbprint}.pfx"), - }) - .AddExecutionConfigurationGatherer(new OtlpEndpointReferenceGatherer()) - .BuildAsync(_executionContext, resourceLogger, cancellationToken) - .ConfigureAwait(false); + private async Task<(IExecutionConfigurationResult, ContainerPemCertificates?, List?)> + BuildContainerConfiguration(RenderedModelResource cr, ILogger resourceLogger, CancellationToken cancellationToken) + { + var certificatesDestination = ContainerCertificatePathsAnnotation.DefaultCustomCertificatesDestination; + var bundlePaths = ContainerCertificatePathsAnnotation.DefaultCertificateBundlePaths.ToList(); + var certificateDirsPaths = ContainerCertificatePathsAnnotation.DefaultCertificateDirectoriesPaths.ToList(); - List customBundleFiles = new(); + if (cr.ModelResource.TryGetLastAnnotation(out var pathsAnnotation)) + { + certificatesDestination = pathsAnnotation.CustomCertificatesDestination ?? certificatesDestination; + bundlePaths = pathsAnnotation.DefaultCertificateBundles ?? bundlePaths; + certificateDirsPaths = pathsAnnotation.DefaultCertificateDirectories ?? certificateDirsPaths; + } - // Add the certificates to the executable spec so they'll be placed in the DCP config - ContainerPemCertificates? pemCertificates = null; - if (configuration.TryGetAdditionalData(out var certificateTrustConfiguration) - && certificateTrustConfiguration.Scope != CertificateTrustScope.None - && certificateTrustConfiguration.Certificates.Count > 0) - { - pemCertificates = new ContainerPemCertificates - { - Certificates = certificateTrustConfiguration.Certificates.Select(c => - { - return new PemCertificate - { - Thumbprint = c.Thumbprint, - Contents = c.ExportCertificatePem(), - }; - }).DistinctBy(cert => cert.Thumbprint).ToList(), - Destination = certificatesDestination, - ContinueOnError = true, - }; + var serverAuthCertificatesBasePath = $"{certificatesDestination}/private"; - if (certificateTrustConfiguration.Scope != CertificateTrustScope.Append) + var configuration = await ExecutionConfigurationBuilder.Create(cr.ModelResource) + .WithArgumentsConfig() + .WithEnvironmentVariablesConfig() + .WithCertificateTrustConfig(scope => + { + var dirs = new List { certificatesDestination + "/certs" }; + if (scope == CertificateTrustScope.Append) { - // If overriding the default resource CA bundle, then we want to copy our bundle to the well-known locations - // used by common Linux distributions to make it easier to ensure applications pick it up. - // Group by common directory to avoid creating multiple file system entries for the same root directory. - pemCertificates.OverwriteBundlePaths = bundlePaths; + // When appending to the default trust store, include the default certificate directories + dirs.AddRange(certificateDirsPaths!); } - foreach (var bundleFactory in certificateTrustConfiguration.CustomBundlesFactories) + return new() { - var bundleId = bundleFactory.Key; - var bundleBytes = await bundleFactory.Value(certificateTrustConfiguration.Certificates, cancellationToken).ConfigureAwait(false); - - customBundleFiles.Add(new ContainerFileSystemEntry - { - Name = bundleId, - Type = ContainerFileSystemEntryType.File, - RawContents = Convert.ToBase64String(bundleBytes), - }); - } - } + CertificateBundlePath = ReferenceExpression.Create($"{certificatesDestination}/cert.pem"), + // Build Linux PATH style colon-separated list of directories + CertificateDirectoriesPath = ReferenceExpression.Create($"{string.Join(':', dirs)}"), + RootCertificatesPath = certificatesDestination, + IsContainer = true, + }; + }) + .WithHttpsCertificateConfig(cert => new() + { + CertificatePath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{cert.Thumbprint}.crt"), + KeyPath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{cert.Thumbprint}.key"), + PfxPath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{cert.Thumbprint}.pfx"), + }) + .AddExecutionConfigurationGatherer(new OtlpEndpointReferenceGatherer()) + .BuildAsync(_executionContext, resourceLogger, cancellationToken) + .ConfigureAwait(false); - spec.PemCertificates = pemCertificates; + List customBundleFiles = new(); - var buildCreateFilesContext = new BuildCreateFilesContext + // Add the certificates to the Container spec so they'll be placed in the DCP config + ContainerPemCertificates? pemCertificates = null; + if (configuration.TryGetAdditionalData(out var certificateTrustConfiguration) + && certificateTrustConfiguration.Scope != CertificateTrustScope.None + && certificateTrustConfiguration.Certificates.Count > 0) + { + pemCertificates = new ContainerPemCertificates { - Resource = modelContainerResource, - CertificateTrustScope = certificateTrustConfiguration?.Scope ?? CertificateTrustScope.None, - CertificateTrustBundlePath = $"{certificatesDestination}/cert.pem", + Certificates = certificateTrustConfiguration.Certificates.Select(c => + { + return new PemCertificate + { + Thumbprint = c.Thumbprint, + Contents = c.ExportCertificatePem(), + }; + }).DistinctBy(cert => cert.Thumbprint).ToList(), + Destination = certificatesDestination, + ContinueOnError = true, }; - if (configuration.TryGetAdditionalData(out var tlsCertificateConfiguration)) + if (certificateTrustConfiguration.Scope != CertificateTrustScope.Append) { - var thumbprint = tlsCertificateConfiguration.Certificate.Thumbprint; - buildCreateFilesContext.HttpsCertificateContext = new ContainerFileSystemCallbackHttpsCertificateContext - { - CertificatePath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{thumbprint}.crt"), - KeyPath = tlsCertificateConfiguration.KeyPathReference, - PfxPath = tlsCertificateConfiguration.PfxPathReference, - Password = tlsCertificateConfiguration.Password, - }; + // If overriding the default resource CA bundle, then we want to copy our bundle to the well-known locations + // used by common Linux distributions to make it easier to ensure applications pick it up. + // Group by common directory to avoid creating multiple file system entries for the same root directory. + pemCertificates.OverwriteBundlePaths = bundlePaths; } - // Build files that need to be created inside the container - var createFiles = await BuildCreateFilesAsync( - buildCreateFilesContext, - cancellationToken).ConfigureAwait(false); - - if (customBundleFiles.Count > 0) + foreach (var bundleFactory in certificateTrustConfiguration.CustomBundlesFactories) { - createFiles.Add(new ContainerCreateFileSystem + var bundleId = bundleFactory.Key; + var bundleBytes = await bundleFactory.Value(certificateTrustConfiguration.Certificates, cancellationToken).ConfigureAwait(false); + + customBundleFiles.Add(new ContainerFileSystemEntry { - Destination = certificatesDestination, - Entries = [ - new ContainerFileSystemEntry - { - Name = "bundles", - Type = ContainerFileSystemEntryType.Directory, - Entries = customBundleFiles, - }, - ], + Name = bundleId, + Type = ContainerFileSystemEntryType.File, + RawContents = Convert.ToBase64String(bundleBytes), }); } + } - if (tlsCertificateConfiguration is not null) - { - var thumbprint = tlsCertificateConfiguration.Certificate.Thumbprint; - var publicCertificatePem = tlsCertificateConfiguration.Certificate.ExportCertificatePem(); - (var keyPem, var pfxBytes) = await GetCertificateKeyMaterialAsync(tlsCertificateConfiguration, cancellationToken).ConfigureAwait(false); - var certificateFiles = new List() - { - new ContainerFileSystemEntry - { - Name = thumbprint + ".crt", - Type = ContainerFileSystemEntryType.File, - Contents = new string(publicCertificatePem), - } - }; + var buildCreateFilesContext = new BuildCreateFilesContext + { + Resource = cr.ModelResource, + CertificateTrustScope = certificateTrustConfiguration?.Scope ?? CertificateTrustScope.None, + CertificateTrustBundlePath = $"{certificatesDestination}/cert.pem", + }; - if (keyPem is not null) - { - certificateFiles.Add(new ContainerFileSystemEntry - { - Name = thumbprint + ".key", - Type = ContainerFileSystemEntryType.File, - Contents = new string(keyPem), - }); + if (configuration.TryGetAdditionalData(out var tlsCertificateConfiguration)) + { + var thumbprint = tlsCertificateConfiguration.Certificate.Thumbprint; + buildCreateFilesContext.HttpsCertificateContext = new ContainerFileSystemCallbackHttpsCertificateContext + { + CertificatePath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{thumbprint}.crt"), + KeyPath = tlsCertificateConfiguration.KeyPathReference, + PfxPath = tlsCertificateConfiguration.PfxPathReference, + Password = tlsCertificateConfiguration.Password, + }; + } - Array.Clear(keyPem, 0, keyPem.Length); - } + // Build files that need to be created inside the container + var createFiles = await BuildCreateFilesAsync( + buildCreateFilesContext, + cancellationToken).ConfigureAwait(false); - if (pfxBytes is not null) - { - certificateFiles.Add(new ContainerFileSystemEntry + if (customBundleFiles.Count > 0) + { + createFiles.Add(new ContainerCreateFileSystem + { + Destination = certificatesDestination, + Entries = [ + new ContainerFileSystemEntry { - Name = thumbprint + ".pfx", - Type = ContainerFileSystemEntryType.File, - RawContents = Convert.ToBase64String(pfxBytes), - }); + Name = "bundles", + Type = ContainerFileSystemEntryType.Directory, + Entries = customBundleFiles, + }, + ], + }); + } - Array.Clear(pfxBytes, 0, pfxBytes.Length); + if (tlsCertificateConfiguration is not null) + { + var thumbprint = tlsCertificateConfiguration.Certificate.Thumbprint; + var publicCertificatePem = tlsCertificateConfiguration.Certificate.ExportCertificatePem(); + (var keyPem, var pfxBytes) = await GetCertificateKeyMaterialAsync(tlsCertificateConfiguration, cancellationToken).ConfigureAwait(false); + var certificateFiles = new List() + { + new ContainerFileSystemEntry + { + Name = thumbprint + ".crt", + Type = ContainerFileSystemEntryType.File, + Contents = new string(publicCertificatePem), } + }; - // Write the certificate and key to the container filesystem - createFiles.Add(new ContainerCreateFileSystem + if (keyPem is not null) + { + certificateFiles.Add(new ContainerFileSystemEntry { - Destination = serverAuthCertificatesBasePath, - Entries = certificateFiles, + Name = thumbprint + ".key", + Type = ContainerFileSystemEntryType.File, + Contents = new string(keyPem), }); - } - // Set the final args, env vars, and create files on the container spec - var args = configuration.Arguments.Select(a => a.Value); - // Set the final args, env vars, and create files on the container spec - if (modelContainerResource is ContainerResource { ShellExecution: true }) - { - spec.Args = ["-c", $"{string.Join(' ', args)}"]; - } - else - { - spec.Args = args.ToList(); + Array.Clear(keyPem, 0, keyPem.Length); } - dcpContainerResource.SetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, configuration.Arguments.Select(a => new AppLaunchArgumentAnnotation(a.Value, isSensitive: a.IsSensitive))); - spec.Env = configuration.EnvironmentVariables.Select(kvp => new EnvVar { Name = kvp.Key, Value = kvp.Value }).ToList(); - spec.CreateFiles = createFiles; - if (modelContainerResource is ContainerResource containerResource) + if (pfxBytes is not null) { - spec.Command = containerResource.Entrypoint; - } + certificateFiles.Add(new ContainerFileSystemEntry + { + Name = thumbprint + ".pfx", + Type = ContainerFileSystemEntryType.File, + RawContents = Convert.ToBase64String(pfxBytes), + }); - if (failedToApplyRunArgs || configuration.Exception is not null) - { - throw new FailedToApplyEnvironmentException(); + Array.Clear(pfxBytes, 0, pfxBytes.Length); } - if (_dcpInfo is not null) + // Write the certificate and key to the container filesystem + createFiles.Add(new ContainerCreateFileSystem { - DcpDependencyCheck.CheckDcpInfoAndLogErrors(resourceLogger, _options.Value, _dcpInfo); - } - - await _kubernetesService.CreateAsync(dcpContainerResource, cancellationToken).ConfigureAwait(false); - } - finally - { - AspireEventSource.Instance.DcpObjectCreationStop(cr.DcpResource.Kind, cr.DcpResourceName); + Destination = serverAuthCertificatesBasePath, + Entries = certificateFiles, + }); } + + return (configuration, pemCertificates, createFiles); } private static async Task ApplyBuildArgumentsAsync(Container dcpContainerResource, IResource modelContainerResource, IServiceProvider serviceProvider, CancellationToken cancellationToken) @@ -2643,6 +2617,9 @@ public async Task StartResourceAsync(IResourceReference resourceReference, Cance { _logger.LogDebug("Starting {ResourceType} '{ResourceName}'.", resourceType, appResource.DcpResourceName); + // Reset cached callback results so they are re-evaluated on restart. + ForgetCachedCallbackResults(appResource.ModelResource); + // Raise event after resource has been deleted. This is required because the event sets the status to "Starting" and resources being // deleted will temporarily override the status to a terminal state, such as "Exited". switch (appResource.DcpResource) @@ -2653,12 +2630,14 @@ public async Task StartResourceAsync(IResourceReference resourceReference, Cance // Ensure we explicitly start the container c.Spec.Start = true; + await _executorEvents.PublishAsync(new OnConnectionStringAvailableContext(cancellationToken, appResource.ModelResource)).ConfigureAwait(false); await _executorEvents.PublishAsync(new OnResourceStartingContext(cancellationToken, resourceType, appResource.ModelResource, appResource.DcpResourceName)).ConfigureAwait(false); - await CreateContainerAsync(appResource, resourceLogger, cancellationToken).ConfigureAwait(false); + await CreateDcpContainerAsync(appResource, resourceLogger, cancellationToken).ConfigureAwait(false); break; case Executable e: await EnsureResourceDeletedAsync(appResource.DcpResourceName).ConfigureAwait(false); + await _executorEvents.PublishAsync(new OnConnectionStringAvailableContext(cancellationToken, appResource.ModelResource)).ConfigureAwait(false); await _executorEvents.PublishAsync(new OnResourceStartingContext(cancellationToken, resourceType, appResource.ModelResource, appResource.DcpResourceName)).ConfigureAwait(false); await CreateExecutableAsync(appResource, resourceLogger, cancellationToken).ConfigureAwait(false); break; @@ -3041,6 +3020,28 @@ private static bool TryGetEndpoint(IResource resource, string? endpointName, [No return endpoint is not null; } + /// + /// Clears cached callback results on resource annotations so they are re-evaluated on restart. + /// + private static void ForgetCachedCallbackResults(IResource resource) + { + if (resource.TryGetEnvironmentVariables(out var envCallbacks)) + { + foreach (var callback in envCallbacks) + { + ((ICallbackResourceAnnotation>)callback).ForgetCachedResult(); + } + } + + if (resource.TryGetAnnotationsOfType(out var argsCallbacks)) + { + foreach (var callback in argsCallbacks) + { + ((ICallbackResourceAnnotation>)callback).ForgetCachedResult(); + } + } + } + private record struct HostResourceWithEndpoints ( IResourceWithEndpoints Resource, @@ -3061,79 +3062,64 @@ IEnumerable Endpoints return null; } - private record struct ContainerCreationSets - ( - IEnumerable RegularContainers, - IEnumerable TunnelDependentContainers, - IEnumerable RegularContainerExecutables, - IEnumerable TunnelDependentContainerExecutables - ); - /// - /// Determines which containers, and container executables, can be created immediately, - /// and which ones depend on a tunnel to the host network. - /// - /// Cancellation token that can be used to cancel the whole operation. - /// - /// A record grouping container-related resources into sets, dependent on whether they - /// require network tunnels to host resources. - /// - private async Task GetContainerCreationSetsAsync(CancellationToken cancellationToken) + private async Task> GetHostDependenciesAsync(IResource resource, CancellationToken cancellationToken) { - List regular = new(); - List tunnelDependent = new(); + var allDependencies = await ResourceExtensions.GetResourceDependenciesAsync( + resource, + _executionContext, + ResourceDependencyDiscoveryMode.DirectOnly | ResourceDependencyDiscoveryMode.CacheAnnotationCallbackResults, + cancellationToken + ).ConfigureAwait(false); - var containers = _appResources.OfType().Where(ar => ar.DcpResource is Container); + // Host dependencies are host network resources with endpoints that containers depend on. + List hostDependencies = [.. allDependencies.Select(AsHostResourceWithEndpoints).OfType()]; - if (!_options.Value.EnableAspireContainerTunnel) - { - regular.AddRange(containers); - } - else + // Aspire dashboard is special in the context of Open Telemetry ingestion. + // OTLP exporters do not refer to the OTLP ingestion endpoint via EndpointReference when the model is constructed + // by the Aspire app host; the endpoint URL is just read from configuration. + // If there are containers that are OTLP exporters in the model, we need to project dashboard endpoints into container space. + if (resource.TryGetAnnotationsOfType(out _)) { - foreach (var cr in containers) + var maybeDashboard = _model.Resources.Where(r => StringComparers.ResourceName.Equals(r.Name, KnownResourceNames.AspireDashboard)) + .Select(AsHostResourceWithEndpoints).FirstOrDefault(); + if (maybeDashboard is HostResourceWithEndpoints dashboardResource) { - cancellationToken.ThrowIfCancellationRequested(); - - var dependencies = await cr.ModelResource.GetResourceDependenciesAsync(_executionContext, ResourceDependencyDiscoveryMode.DirectOnly, cancellationToken).ConfigureAwait(false); - - if (dependencies.Any(dep => AsHostResourceWithEndpoints(dep) is { })) - { - tunnelDependent.Add(cr); - } - else - { - regular.Add(cr); - } + hostDependencies.Add(dashboardResource); } } - var persistentTunnelDependent = tunnelDependent.Where(td => td.DcpResource is Container c && c.Spec.Persistent is true); - if (persistentTunnelDependent.Any()) - { - var containerNames = persistentTunnelDependent.Select(td => td.ModelResource.Name).Aggregate(string.Empty, (acc, next) => acc + " '" + next + "'"); - throw new InvalidOperationException($"The follwing containers are marked as persistent and rely on resources on the host network:{containerNames}. This is not supported."); - } + return hostDependencies; + } - return new ContainerCreationSets( - RegularContainers: regular, - TunnelDependentContainers: tunnelDependent, - RegularContainerExecutables: _appResources.OfType() - .Where(ar => ar.DcpResource is ContainerExec ce && regular.Any(td => td.DcpResource is Container c && c.Metadata.Name == ce.Spec.ContainerName)), - TunnelDependentContainerExecutables: _appResources.OfType() - .Where(ar => ar.DcpResource is ContainerExec ce && tunnelDependent.Any(td => td.DcpResource is Container c && c.Metadata.Name == ce.Spec.ContainerName)) - ); + private AppResource CreateTunnelProxyResource(List? tunnels) + { + Debug.Assert(_options.Value.EnableAspireContainerTunnel, "This method should only be called if the container tunnel feature is enabled."); + Debug.Assert(!_appResources.Any(ar => ar.DcpResource is ContainerNetworkTunnelProxy), "This method should only be called if a tunnel proxy resource hasn't already been created."); + + var tunnelProxy = ContainerNetworkTunnelProxy.Create(GetTunnelProxyResourceName()); + tunnelProxy.Spec.ContainerNetworkName = KnownNetworkIdentifiers.DefaultAspireContainerNetwork.Value; + tunnelProxy.Spec.Aliases = [ContainerHostName]; + tunnelProxy.Spec.Tunnels = tunnels; + var tunnelAppResource = new AppResource(tunnelProxy); + _appResources.Add(tunnelAppResource); + return tunnelAppResource; + } + + private string GetTunnelProxyResourceName() + { + Debug.Assert(_options.Value.EnableAspireContainerTunnel, "This method should only be called if the container tunnel feature is enabled."); + return KnownNetworkIdentifiers.DefaultAspireContainerNetwork.Value + "-tunnelproxy"; } private async Task PublishEndpointAllocatedEventAsync( - HashSet endpointsAdvertisedFor, IEnumerable resource, CancellationToken ct) { foreach (var r in resource) { - lock (endpointsAdvertisedFor) + lock (_endpointsAdvertised) { - if (!endpointsAdvertisedFor.Add(r.ModelResource.Name)) + if (!_endpointsAdvertised.Add(r.ModelResource.Name)) { continue; // Already published for this resource } diff --git a/src/Aspire.Hosting/Dcp/DcpExecutorEvents.cs b/src/Aspire.Hosting/Dcp/DcpExecutorEvents.cs index 9569cd62dd8..d5f45145841 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutorEvents.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutorEvents.cs @@ -9,6 +9,7 @@ namespace Aspire.Hosting.Dcp; internal record ResourceStatus(string? State, DateTime? StartupTimestamp, DateTime? FinishedTimestamp); internal record OnEndpointsAllocatedContext(CancellationToken CancellationToken); internal record OnResourceStartingContext(CancellationToken CancellationToken, string ResourceType, IResource Resource, string? DcpResourceName); +internal record OnConnectionStringAvailableContext(CancellationToken CancellationToken, IResource Resource); internal record OnResourcesPreparedContext(CancellationToken CancellationToken); internal record OnResourceChangedContext(CancellationToken CancellationToken, string ResourceType, IResource Resource, string DcpResourceName, ResourceStatus Status, Func UpdateSnapshot); internal record OnResourceFailedToStartContext(CancellationToken CancellationToken, string ResourceType, IResource Resource, string? DcpResourceName); diff --git a/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs b/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs index bf24f738865..742ce696b83 100644 --- a/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs +++ b/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs @@ -19,6 +19,13 @@ internal sealed class DcpNameGenerator private readonly IConfiguration _configuration; private readonly IOptions _options; + // A map from (resource name, endpoint name, target network ID) => DCP service name. + // Used for ensuring that we do not create duplicate DCP services for the same resource endpoint. + private readonly Dictionary _networkServices = new(); + + // Helps ensure that service names are unique (service names do not use random suffixes). + private readonly HashSet _allServiceNames = new(); + public DcpNameGenerator(IConfiguration configuration, IOptions options) { _configuration = configuration; @@ -77,32 +84,40 @@ private static void AddInstancesAnnotation(IResource resource, ImmutableArray allServiceNames) + // Returns a DCP service name for the given resource/endpoint/network combination. + // The returned boolean indicates whether a new service name was generated (true) or an existing one was returned (false). + public (string, bool) GetServiceName(IResource resource, EndpointAnnotation endpoint, NetworkIdentifier targetNetworkId) { - var candidateServiceName = !hasMultipleEndpoints - ? GetObjectNameForResource(resource, _options.Value) - : GetObjectNameForResource(resource, _options.Value, endpoint.Name); + var hasMultipleEndpoints = resource.Annotations.OfType().Count() > 1; + var key = NetworkServiceKey(resource, endpoint, targetNetworkId); - return GenerateUniqueServiceName(allServiceNames, candidateServiceName); - } + lock(_allServiceNames) + { + if (_networkServices.TryGetValue(key, out var name)) + { + return (name, false); + } - private static string GenerateUniqueServiceName(HashSet serviceNames, string candidateName) - { - int suffix = 1; - string uniqueName = candidateName; + var candidateName = !hasMultipleEndpoints + ? GetObjectNameForResource(resource, _options.Value) + : GetObjectNameForResource(resource, _options.Value, endpoint.Name); - while (!serviceNames.Add(uniqueName)) - { - uniqueName = $"{candidateName}-{suffix}"; - suffix++; - if (suffix == 100) + int suffix = 1; + string uniqueName = candidateName; + + while (!_allServiceNames.Add(uniqueName)) { - // Should never happen, but we do not want to ever get into a infinite loop situation either. - throw new ArgumentException($"Could not generate a unique name for service '{candidateName}'"); + uniqueName = $"{candidateName}-{suffix}"; + suffix++; + if (suffix == 100) + { + // Should never happen, but we do not want to ever get into a infinite loop situation either. + throw new ArgumentException($"Could not generate a unique name for service '{candidateName}'"); + } } + _networkServices[key] = uniqueName; + return (uniqueName, true); } - - return uniqueName; } public static string GetRandomNameSuffix() @@ -137,4 +152,7 @@ static string maybeWithSuffix(string s, string localSuffix, string? globalSuffix }; return maybeWithSuffix(resource.Name, suffix, options.ResourceNameSuffix); } + + private static string NetworkServiceKey(IResource resource, EndpointAnnotation endpoint, NetworkIdentifier targetNetworkId) + => $"{resource.Name}|{endpoint.Name}|{targetNetworkId.Value}"; } diff --git a/src/Aspire.Hosting/Dcp/DcpResourceState.cs b/src/Aspire.Hosting/Dcp/DcpResourceState.cs index 42bd58d9636..d67c74a8f5f 100644 --- a/src/Aspire.Hosting/Dcp/DcpResourceState.cs +++ b/src/Aspire.Hosting/Dcp/DcpResourceState.cs @@ -7,7 +7,7 @@ namespace Aspire.Hosting.Dcp; -internal sealed class DcpResourceState(Dictionary applicationModel, List appResources) +internal sealed class DcpResourceState(IDictionary applicationModel, IEnumerable appResources) { public readonly ConcurrentDictionary ContainersMap = []; public readonly ConcurrentDictionary ExecutablesMap = []; @@ -16,6 +16,6 @@ internal sealed class DcpResourceState(Dictionary application public readonly ConcurrentDictionary EndpointsMap = []; public readonly ConcurrentDictionary<(string, string), List> ResourceAssociatedServicesMap = []; - public Dictionary ApplicationModel { get; } = applicationModel; - public List AppResources { get; } = appResources; + public IDictionary ApplicationModel { get; } = applicationModel; + public IEnumerable AppResources { get; } = appResources; } diff --git a/src/Aspire.Hosting/Dcp/Model/ContainerTunnel.cs b/src/Aspire.Hosting/Dcp/Model/ContainerTunnel.cs index 2ad50918304..8aba89797a6 100644 --- a/src/Aspire.Hosting/Dcp/Model/ContainerTunnel.cs +++ b/src/Aspire.Hosting/Dcp/Model/ContainerTunnel.cs @@ -259,8 +259,17 @@ internal sealed record ContainerNetworkTunnelProxyStatus : V1Status internal sealed class ContainerNetworkTunnelProxy : CustomResource, IKubernetesStaticMetadata { + /// + /// Contains updated tunnel configurations that have not yet been applied to the proxy pair. + /// + [JsonIgnore] + public List UpdatedTunnels { get; private set; } + [JsonConstructor] - public ContainerNetworkTunnelProxy(ContainerNetworkTunnelProxySpec spec) : base(spec) { } + public ContainerNetworkTunnelProxy(ContainerNetworkTunnelProxySpec spec) : base(spec) + { + UpdatedTunnels = spec.Tunnels ?? new List(); + } public static ContainerNetworkTunnelProxy Create(string name) { diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs index 146ca22f63b..93709b1596d 100644 --- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs +++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs @@ -68,6 +68,7 @@ public ApplicationOrchestrator(DistributedApplicationModel model, dcpExecutorEvents.Subscribe(OnResourceChanged); dcpExecutorEvents.Subscribe(OnEndpointsAllocated); dcpExecutorEvents.Subscribe(OnResourceStarting); + dcpExecutorEvents.Subscribe(OnConnectionStringAvailable); dcpExecutorEvents.Subscribe(OnResourceFailedToStart); _eventing.Subscribe(OnResourceEndpointsAllocated); @@ -193,8 +194,6 @@ await PublishUpdateAsync(_notificationService, context.Resource, context.DcpReso break; } - await PublishConnectionStringAvailableEvent(context.Resource, context.CancellationToken).ConfigureAwait(false); - var beforeResourceStartedEvent = new BeforeResourceStartedEvent(context.Resource, _serviceProvider); await _eventing.PublishAsync(beforeResourceStartedEvent, context.CancellationToken).ConfigureAwait(false); @@ -211,6 +210,11 @@ private async Task OnResourcesPrepared(OnResourcesPreparedContext context) await PublishResourcesInitialStateAsync(context.CancellationToken).ConfigureAwait(false); } + private async Task OnConnectionStringAvailable(OnConnectionStringAvailableContext context) + { + await PublishConnectionStringAvailableEvent(context.Resource, context.CancellationToken).ConfigureAwait(false); + } + private async Task ProcessResourceUrlCallbacks(IResource resource, CancellationToken cancellationToken) { var urls = new List(); diff --git a/src/Aspire.Hosting/Utils/ConcurrencyUtils.cs b/src/Aspire.Hosting/Utils/ConcurrencyUtils.cs new file mode 100644 index 00000000000..03b5d4aa66d --- /dev/null +++ b/src/Aspire.Hosting/Utils/ConcurrencyUtils.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; + +namespace Aspire.Hosting.Utils; + +internal static class ConcurrencyUtils +{ + public static Action Once(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + var invoked = 0; + + return () => + { + if (Interlocked.CompareExchange(ref invoked, 1, 0) != 0) + { + return; + } + + action(); + }; + } + + public static void AddRange(this ConcurrentBag bag, IEnumerable items) + { + ArgumentNullException.ThrowIfNull(bag); + ArgumentNullException.ThrowIfNull(items); + + foreach (var item in items) + { + bag.Add(item); + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs b/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs index 24834d53f4c..5f4927bb7e3 100644 --- a/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs +++ b/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs @@ -45,12 +45,11 @@ public async Task ContainerTunnelWorksWithYarp() [Fact] [RequiresFeature(TestFeature.Docker | TestFeature.DockerPluginBuildx)] - [ActiveIssue("https://github.com/microsoft/aspire/issues/15358")] public async Task ProxylessEndpointWorksWithContainerTunnel() { var port = await Helpers.Network.GetAvailablePortAsync(); - const string testName = "proxyless-endpoint-works-with-container-tunnel"; + const string testName = "proxyless-endpoint-container-tunnel"; using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); builder.Configuration[KnownConfigNames.EnableContainerTunnel] = "true"; diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 6b27966356b..933e9d91647 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -2323,6 +2323,266 @@ async ValueTask AssertEndpoint(IResourceWithEndpoints resource, string name, Net } } + // Verifies that environment value callbacks are invoked only once per container startup. + [Fact] + public async Task EnvironmentCallbacksInvokedOnceOnContainer() + { + var builder = DistributedApplication.CreateBuilder(); + + var executable = builder.AddExecutable("anExecutable", "command", "") + .WithEndpoint(name: "http", targetPort: 1234, port: 5678, isProxied: true); + + var callCount = 0; + builder.AddContainer("aContainer", "image") + .WithEnvironment(c => + { + Interlocked.Increment(ref callCount); + c.EnvironmentVariables["EXEC_PORT"] = executable.GetEndpoint("http").Property(EndpointProperty.Port); + }); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + var dcpOptions = new DcpOptions + { + EnableAspireContainerTunnel = true, + }; + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions); + await appExecutor.RunApplicationAsync(); + + Assert.Equal(1, callCount); + } + + // Ensures that environment value callbacks are invoked after the OnResourceStarting event is raised for the resource, + // allowing users to rely on any state set during that event in their environment callbacks. + [Fact] + public async Task EnvironmentCallbacksInvokedAfterBeforeResourceStartEvent() + { + var builder = DistributedApplication.CreateBuilder(); + var envCallCount = 0; + var resourceStartingRaised = false; + var resourceStartingCalledBeforeEnvCallback = false; + + var executable = builder.AddExecutable("anExecutable", "command", "") + .WithEndpoint(name: "http", targetPort: 1234, port: 5678, isProxied: true); + + builder.AddContainer("aContainer", "image") + .WithEnvironment(c => + { + Interlocked.Increment(ref envCallCount); + c.EnvironmentVariables["EXEC_PORT"] = executable.GetEndpoint("http").Property(EndpointProperty.Port); + }); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + var events = new DcpExecutorEvents(); + events.Subscribe(context => + { + if (context.ResourceType == "Container") + { + resourceStartingRaised = true; + resourceStartingCalledBeforeEnvCallback = envCallCount == 0; + } + return Task.CompletedTask; + }); + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, events: events); + await appExecutor.RunApplicationAsync(); + + Assert.Equal(1, envCallCount); + Assert.True(resourceStartingRaised, "OnResourceStarting should be raised for the container"); + Assert.True(resourceStartingCalledBeforeEnvCallback, "OnResourceStarting should be raised before the environment callback is invoked"); + } + + // Verifies that command-line argument callbacks are invoked only once per container startup. + [Fact] + public async Task ArgsCallbacksInvokedOnceOnContainer() + { + var builder = DistributedApplication.CreateBuilder(); + + var executable = builder.AddExecutable("anExecutable", "command", "") + .WithEndpoint(name: "http", targetPort: 1234, port: 5678, isProxied: true); + + var callCount = 0; + builder.AddContainer("aContainer", "image") + .WithArgs(c => + { + Interlocked.Increment(ref callCount); + c.Args.Add("--port"); + c.Args.Add(executable.GetEndpoint("http").Property(EndpointProperty.Port)); + }); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + var dcpOptions = new DcpOptions + { + EnableAspireContainerTunnel = true, + }; + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions); + await appExecutor.RunApplicationAsync(); + + Assert.Equal(1, callCount); + } + + // Ensures that command-line argument callbacks are invoked after the OnResourceStarting event is raised for the resource, + // allowing users to rely on any state set during that event in their argument callbacks. + [Fact] + public async Task ArgsCallbacksInvokedAfterBeforeResourceStartEvent() + { + var builder = DistributedApplication.CreateBuilder(); + var argsCallCount = 0; + var resourceStartingRaised = false; + var resourceStartingCalledBeforeArgsCallback = false; + + var executable = builder.AddExecutable("anExecutable", "command", "") + .WithEndpoint(name: "http", targetPort: 1234, port: 5678, isProxied: true); + + builder.AddContainer("aContainer", "image") + .WithArgs(c => + { + Interlocked.Increment(ref argsCallCount); + c.Args.Add("--port"); + c.Args.Add(executable.GetEndpoint("http").Property(EndpointProperty.Port)); + }); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + var events = new DcpExecutorEvents(); + events.Subscribe(context => + { + if (context.ResourceType == "Container") + { + resourceStartingRaised = true; + resourceStartingCalledBeforeArgsCallback = argsCallCount == 0; + } + return Task.CompletedTask; + }); + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, events: events); + await appExecutor.RunApplicationAsync(); + + Assert.Equal(1, argsCallCount); + Assert.True(resourceStartingRaised, "OnResourceStarting should be raised for the container"); + Assert.True(resourceStartingCalledBeforeArgsCallback, "OnResourceStarting should be raised before the args callback is invoked"); + } + + [Fact] + public async Task TunnelDependentAndIndependentContainersCanStartTogether() + { + var builder = DistributedApplication.CreateBuilder(); + + // An executable with an endpoint — containers that reference it will be tunnel-dependent. + var executable = builder.AddExecutable("anExecutable", "command", "") + .WithEndpoint(name: "http", targetPort: 1234, port: 5678, isProxied: true); + + // A container that references the executable's endpoint — this makes it tunnel-dependent. + builder.AddContainer("tunnelDependent", "image") + .WithEnvironment("EXEC_PORT", executable.GetEndpoint("http").Property(EndpointProperty.Port)); + + // A container that does NOT reference any host resource — this is tunnel-independent. + builder.AddContainer("tunnelIndependent", "image"); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + var dcpOptions = new DcpOptions + { + EnableAspireContainerTunnel = true, + }; + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions); + await appExecutor.RunApplicationAsync(); + + // Both containers should have been created successfully. + var createdContainers = kubernetesService.CreatedResources.OfType().ToList(); + Assert.Single(createdContainers, c => c.AppModelResourceName == "tunnelDependent"); + Assert.Single(createdContainers, c => c.AppModelResourceName == "tunnelIndependent"); + } + + [Fact] + public async Task EnvironmentCallbackThrows_OtherResourcesStillStart() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddContainer("failing", "image") + .WithEnvironment(c => + { + throw new InvalidOperationException("env callback failure"); + }); + + builder.AddContainer("healthy", "image"); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + var failedResources = new List(); + var events = new DcpExecutorEvents(); + events.Subscribe(c => + { + failedResources.Add(c.Resource.Name); + return Task.CompletedTask; + }); + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, events: events); + await appExecutor.RunApplicationAsync(); + + // The healthy container should have been created successfully. + var createdContainers = kubernetesService.CreatedResources.OfType().ToList(); + Assert.Single(createdContainers, c => c.AppModelResourceName == "healthy"); + + // The failing container should not have been created and should be reported as failed. + Assert.DoesNotContain(createdContainers, c => c.AppModelResourceName == "failing"); + Assert.Single(failedResources, name => name == "failing"); + } + + [Fact] + public async Task ArgsCallbackThrows_OtherResourcesStillStart() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddContainer("failing", "image") + .WithArgs(c => + { + throw new InvalidOperationException("args callback failure"); + }); + + builder.AddContainer("healthy", "image"); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + var failedResources = new List(); + var events = new DcpExecutorEvents(); + events.Subscribe(c => + { + failedResources.Add(c.Resource.Name); + return Task.CompletedTask; + }); + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, events: events); + await appExecutor.RunApplicationAsync(); + + // The healthy container should have been created successfully. + var createdContainers = kubernetesService.CreatedResources.OfType().ToList(); + Assert.Single(createdContainers, c => c.AppModelResourceName == "healthy"); + + // The failing container should not have been created and should be reported as failed. + Assert.DoesNotContain(createdContainers, c => c.AppModelResourceName == "failing"); + Assert.Single(failedResources, name => name == "failing"); + } + private static void HasKnownCommandAnnotations(IResource resource) { var commandAnnotations = resource.Annotations.OfType().ToList(); @@ -2587,8 +2847,7 @@ private static DcpExecutor CreateAppExecutor( new TestDcpDependencyCheckService(), new DcpNameGenerator(configuration, Options.Create(dcpOptions)), events ?? new DcpExecutorEvents(), - new Locations(new FileSystemService(configuration ?? new ConfigurationBuilder().Build())), - developerCertificateService); + new Locations(new FileSystemService(configuration ?? new ConfigurationBuilder().Build()))); #pragma warning restore ASPIRECERTIFICATES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. } diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs index ccf353d8037..f6d7dd6e4d5 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs @@ -401,7 +401,7 @@ public async Task GrandChildResourceWithConnectionString() return Task.CompletedTask; }); - await events.PublishAsync(new OnResourceStartingContext(CancellationToken.None, KnownResourceTypes.Container, parentResource.Resource, parentResource.Resource.Name)); + await events.PublishAsync(new OnConnectionStringAvailableContext(CancellationToken.None, parentResource.Resource)); Assert.True(parentConnectionStringAvailable); Assert.True(childConnectionStringAvailable);