diff --git a/src/Aspire.Hosting/ApplicationModel/ContainerExecutableResource.cs b/src/Aspire.Hosting/ApplicationModel/ContainerExecutableResource.cs new file mode 100644 index 00000000000..c23b68052ef --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ContainerExecutableResource.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Executable resource that runs in a container. +/// +internal class ContainerExecutableResource(string name, ContainerResource containerResource, string command, string? workingDirectory) + : Resource(name), IResourceWithEnvironment, IResourceWithArgs, IResourceWithEndpoints, IResourceWithWaitSupport +{ + /// + /// Gets the command associated with this executable resource. + /// + public string Command { get; } = ThrowIfNullOrEmpty(command); + + /// + /// Gets the working directory for the executable resource. + /// + public string? WorkingDirectory { get; } = workingDirectory; + + /// + /// Args of the command to run in the container. + /// + public ICollection? Args { get; init; } + + /// + /// Target container resource that this executable runs in. + /// + public ContainerResource? TargetContainerResource { get; } = containerResource ?? throw new ArgumentNullException(nameof(containerResource)); + + private static string ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) + { + ArgumentException.ThrowIfNullOrEmpty(argument, paramName); + return argument; + } +} diff --git a/src/Aspire.Hosting/AspireEventSource.cs b/src/Aspire.Hosting/AspireEventSource.cs index 111212fa844..cbd02a0fd10 100644 --- a/src/Aspire.Hosting/AspireEventSource.cs +++ b/src/Aspire.Hosting/AspireEventSource.cs @@ -210,20 +210,20 @@ public void DcpContainersCreateStop() } [Event(23, Level = EventLevel.Informational, Message = "DCP Executable object creation starting...")] - public void DcpExecutablesCreateStart() + public void DcpExecutableCreateStart(string resourceName) { if (IsEnabled()) { - WriteEvent(23); + WriteEvent(23, resourceName); } } [Event(24, Level = EventLevel.Informational, Message = "DCP Executable object creation completed")] - public void DcpExecutablesCreateStop() + public void DcpExecutableCreateStop(string resourceName) { if (IsEnabled()) { - WriteEvent(24); + WriteEvent(24, resourceName); } } @@ -280,4 +280,22 @@ public void DcpVersionCheckStop() WriteEvent(30); } } + + [Event(31, Level = EventLevel.Informational, Message = "DCP Container Executable object creation starting...")] + public void DcpContainerExecutableCreateStart(string resourceName) + { + if (IsEnabled()) + { + WriteEvent(31, resourceName); + } + } + + [Event(32, Level = EventLevel.Informational, Message = "DCP Container Executable object creation completed")] + public void DcpContainerExecutableCreateStop(string resourceName) + { + if (IsEnabled()) + { + WriteEvent(32, resourceName); + } + } } diff --git a/src/Aspire.Hosting/BuiltInDistributedApplicationEventSubscriptionHandlers.cs b/src/Aspire.Hosting/BuiltInDistributedApplicationEventSubscriptionHandlers.cs index d0c42312e46..b3375df8cde 100644 --- a/src/Aspire.Hosting/BuiltInDistributedApplicationEventSubscriptionHandlers.cs +++ b/src/Aspire.Hosting/BuiltInDistributedApplicationEventSubscriptionHandlers.cs @@ -25,6 +25,11 @@ public static Task InitializeDcpAnnotations(BeforeStartEvent beforeStartEvent, C nameGenerator.EnsureDcpInstancesPopulated(executable); } + foreach (var containerExec in beforeStartEvent.Model.GetContainerExecutableResources()) + { + nameGenerator.EnsureDcpInstancesPopulated(containerExec); + } + foreach (var project in beforeStartEvent.Model.GetProjectResources()) { nameGenerator.EnsureDcpInstancesPopulated(project); diff --git a/src/Aspire.Hosting/ContainerExecutableResourceExtensions.cs b/src/Aspire.Hosting/ContainerExecutableResourceExtensions.cs new file mode 100644 index 00000000000..c58cdf75d9f --- /dev/null +++ b/src/Aspire.Hosting/ContainerExecutableResourceExtensions.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for working with objects. +/// +internal static class ContainerExecutableResourceExtensions +{ + /// + /// Returns an enumerable collection of executable resources from the specified distributed application model. + /// + /// The distributed application model to retrieve executable resources from. + /// An enumerable collection of executable resources. + public static IEnumerable GetContainerExecutableResources(this DistributedApplicationModel model) + { + ArgumentNullException.ThrowIfNull(model); + + return model.Resources.OfType(); + } +} diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 47598cd710c..891e3957ac7 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -247,6 +247,7 @@ private void WatchResourceChanges() await Task.WhenAll( Task.Run(() => WatchKubernetesResourceAsync((t, r) => ProcessResourceChange(t, r, _resourceState.ExecutablesMap, "Executable", (e, s) => _snapshotBuilder.ToSnapshot(e, s)))), Task.Run(() => WatchKubernetesResourceAsync((t, r) => ProcessResourceChange(t, r, _resourceState.ContainersMap, "Container", (c, s) => _snapshotBuilder.ToSnapshot(c, s)))), + Task.Run(() => WatchKubernetesResourceAsync((t, r) => ProcessResourceChange(t, r, _resourceState.ContainerExecsMap, "ContainerExec", (c, s) => _snapshotBuilder.ToSnapshot(c, s)))), Task.Run(() => WatchKubernetesResourceAsync(ProcessServiceChange)), Task.Run(() => WatchKubernetesResourceAsync(ProcessEndpointChange))).ConfigureAwait(false); } @@ -297,6 +298,10 @@ await Task.WhenAll( { StartLogStream(executable); } + else if (_resourceState.ContainerExecsMap.TryGetValue(entry.ResourceName, out var containerExec)) + { + StartLogStream(containerExec); + } } else { @@ -396,7 +401,8 @@ private async Task ProcessResourceChange(WatchEventType watchEventType, T res await _executorEvents.PublishAsync(new OnResourceChangedContext(_shutdownCancellation.Token, resourceType, appModelResource, resource.Metadata.Name, status, s => snapshotFactory(resource, s))).ConfigureAwait(false); if (resource is Container { LogsAvailable: true } || - resource is Executable { LogsAvailable: true }) + resource is Executable { LogsAvailable: true } || + resource is ContainerExec { LogsAvailable: true }) { _logInformationChannel.Writer.TryWrite(new(resource.Metadata.Name, LogsAvailable: true, HasSubscribers: null)); } @@ -478,6 +484,7 @@ private static string GetResourceType(T resource, IResource appModelResource) { Container => KnownResourceTypes.Container, Executable => appModelResource is ProjectResource ? KnownResourceTypes.Project : KnownResourceTypes.Executable, + ContainerExec => KnownResourceTypes.ContainerExec, _ => throw new InvalidOperationException($"Unknown resource type {resource.GetType().Name}") }; } @@ -498,6 +505,11 @@ private static ResourceStatus GetResourceStatus(CustomResource resource) { return new(executable.Status?.State, executable.Status?.StartupTimestamp?.ToUniversalTime(), executable.Status?.FinishTimestamp?.ToUniversalTime()); } + if (resource is ContainerExec containerExec) + { + return new(containerExec.Status?.State, containerExec.Status?.StartupTimestamp?.ToUniversalTime(), containerExec.Status?.FinishTimestamp?.ToUniversalTime()); + } + return new(null, null, null); } @@ -512,6 +524,10 @@ public async IAsyncEnumerable> GetAllLogsAsync(string re { enumerable = new ResourceLogSource(_logger, _kubernetesService, executable, follow: false); } + else if (_resourceState.ContainerExecsMap.TryGetValue(resourceName, out var containerExec)) + { + enumerable = new ResourceLogSource(_logger, _kubernetesService, containerExec, follow: false); + } if (enumerable != null) { @@ -551,6 +567,7 @@ private void StartLogStream(T resource) where T : CustomResource { Container c when c.LogsAvailable => new ResourceLogSource(_logger, _kubernetesService, resource, follow: true), Executable e when e.LogsAvailable => new ResourceLogSource(_logger, _kubernetesService, resource, follow: true), + ContainerExec e when e.LogsAvailable => new ResourceLogSource(_logger, _kubernetesService, resource, follow: true), _ => null }; @@ -638,6 +655,7 @@ private async ValueTask TryRefreshResource(string resourceKind, string resourceN CustomResource? cr = resourceKind switch { "Container" => _resourceState.ContainersMap.TryGetValue(resourceName, out var container) ? container : null, + "ContainerExec" => _resourceState.ContainerExecsMap.TryGetValue(resourceName, out var containerExec) ? containerExec : null, "Executable" => _resourceState.ExecutablesMap.TryGetValue(resourceName, out var executable) ? executable : null, _ => null }; @@ -660,6 +678,10 @@ await _executorEvents.PublishAsync(new OnResourceChangedContext(_shutdownCancell { return _snapshotBuilder.ToSnapshot(exe, s); } + else if (cr is ContainerExec containerExec) + { + return _snapshotBuilder.ToSnapshot(containerExec, s); + } return s; })).ConfigureAwait(false); } @@ -774,7 +796,7 @@ private async Task CreateContainerNetworksAsync(CancellationToken cancellationTo private async Task CreateContainersAndExecutablesAsync(CancellationToken cancellationToken) { - var toCreate = _appResources.Where(r => r.DcpResource is Container || r.DcpResource is Executable); + var toCreate = _appResources.Where(r => r.DcpResource is Container or Executable or ContainerExec); AddAllocatedEndpointInfo(toCreate); await _executorEvents.PublishAsync(new OnEndpointsAllocatedContext(cancellationToken)).ConfigureAwait(false); @@ -795,8 +817,9 @@ private async Task CreateContainersAndExecutablesAsync(CancellationToken cancell var containersTask = CreateContainersAsync(toCreate.Where(ar => ar.DcpResource is Container), cancellationToken); var executablesTask = CreateExecutablesAsync(toCreate.Where(ar => ar.DcpResource is Executable), cancellationToken); + var containerExecsTask = CreateContainerExecutablesAsync(toCreate.Where(ar => ar.DcpResource is ContainerExec), cancellationToken); - await Task.WhenAll(containersTask, executablesTask).WaitAsync(cancellationToken).ConfigureAwait(false); + await Task.WhenAll(containersTask, executablesTask, containerExecsTask).WaitAsync(cancellationToken).ConfigureAwait(false); } private void AddAllocatedEndpointInfo(IEnumerable resources) @@ -888,6 +911,36 @@ private void PrepareExecutables() { PrepareProjectExecutables(); PreparePlainExecutables(); + PrepareContainerExecutables(); + } + + private void PrepareContainerExecutables() + { + var modelContainerExecutableResources = _model.GetContainerExecutableResources(); + foreach (var containerExecutable in modelContainerExecutableResources) + { + EnsureRequiredAnnotations(containerExecutable); + var exeInstance = GetDcpInstance(containerExecutable, instanceIndex: 0); + + // Container exec runs against a dcp container resource, so its required to resolve a DCP name of the resource + // since this is ContainerExec resource, we will run against one of the container instances + var containerDcpName = containerExecutable.TargetContainerResource!.GetResolvedResourceName(); + + var containerExec = ContainerExec.Create( + name: exeInstance.Name, + containerName: containerDcpName, + command: containerExecutable.Command, + args: containerExecutable.Args?.ToList(), + workingDirectory: containerExecutable.WorkingDirectory); + + containerExec.Annotate(CustomResource.OtelServiceNameAnnotation, containerExecutable.Name); + containerExec.Annotate(CustomResource.OtelServiceInstanceIdAnnotation, exeInstance.Suffix); + containerExec.Annotate(CustomResource.ResourceNameAnnotation, containerExecutable.Name); + SetInitialResourceState(containerExecutable, containerExec); + + var exeAppResource = new AppResource(containerExecutable, containerExec); + _appResources.Add(exeAppResource); + } } private void PreparePlainExecutables() @@ -1026,80 +1079,110 @@ private static void SetInitialResourceState(IResource resource, IAnnotationHolde } } - private Task CreateExecutablesAsync(IEnumerable executableResources, CancellationToken cancellationToken) + private Task CreateSnapshotableResourcesAsync( + Func createResourceFunc, + IEnumerable executables, + CancellationToken cancellationToken) { - try + async Task CreateResourceExecutablesAsyncCore(IResource resource, IEnumerable executables, CancellationToken cancellationToken) { - AspireEventSource.Instance.DcpExecutablesCreateStart(); + var resourceLogger = _loggerService.GetLogger(resource); + var resourceType = resource is ProjectResource ? KnownResourceTypes.Project : KnownResourceTypes.Executable; - async Task CreateResourceExecutablesAsyncCore(IResource resource, IEnumerable executables, CancellationToken cancellationToken) + try { - var resourceLogger = _loggerService.GetLogger(resource); - var resourceType = resource is ProjectResource ? KnownResourceTypes.Project : KnownResourceTypes.Executable; - - try + // Publish snapshots built from DCP resources. 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. + foreach (var er in executables) { - // Publish snapshots built from DCP resources. 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. - foreach (var er in executables) + Func snapshotBuild = er.DcpResource switch { - await _executorEvents.PublishAsync(new OnResourceChangedContext(_shutdownCancellation.Token, resourceType, resource, er.DcpResourceName, new ResourceStatus(null, null, null), s => _snapshotBuilder.ToSnapshot((Executable)er.DcpResource, s))).ConfigureAwait(false); - } + Executable exe => s => _snapshotBuilder.ToSnapshot(exe, s), + ContainerExec exe => s => _snapshotBuilder.ToSnapshot(exe, s), + _ => throw new NotImplementedException($"Does not support snapshots for resources of type like '{er.DcpResourceName}' is ") + }; + + await _executorEvents.PublishAsync(new OnResourceChangedContext( + _shutdownCancellation.Token, resourceType, resource, + er.DcpResourceName, new ResourceStatus(null, null, null), + snapshotBuild) + ).ConfigureAwait(false); + } - await _executorEvents.PublishAsync(new OnResourceStartingContext(cancellationToken, resourceType, resource, DcpResourceName: null)).ConfigureAwait(false); + await _executorEvents.PublishAsync(new OnResourceStartingContext(cancellationToken, resourceType, resource, DcpResourceName: null)).ConfigureAwait(false); - foreach (var er in executables) + foreach (var er in executables) + { + if (er.ModelResource.TryGetAnnotationsOfType(out _)) { - if (er.ModelResource.TryGetAnnotationsOfType(out _)) - { - await _executorEvents.PublishAsync(new OnResourceChangedContext(cancellationToken, resourceType, resource, er.DcpResource.Metadata.Name, new ResourceStatus(KnownResourceStates.NotStarted, null, null), s => s with { State = new ResourceStateSnapshot(KnownResourceStates.NotStarted, null) })).ConfigureAwait(false); - continue; - } + await _executorEvents.PublishAsync(new OnResourceChangedContext(cancellationToken, resourceType, resource, er.DcpResource.Metadata.Name, new ResourceStatus(KnownResourceStates.NotStarted, null, null), s => s with { State = new ResourceStateSnapshot(KnownResourceStates.NotStarted, null) })).ConfigureAwait(false); + continue; + } - try - { - await CreateExecutableAsync(er, resourceLogger, cancellationToken).ConfigureAwait(false); - } - 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(cancellationToken, resourceType, er.ModelResource, er.DcpResource.Metadata.Name)).ConfigureAwait(false); - } - catch (Exception ex) - { - // The purpose of this catch block is to ensure that if an individual executable resource fails - // to start that it doesn't tear down the entire app host AND that we route the error to the - // appropriate replica. - resourceLogger.LogError(ex, "Failed to create resource {ResourceName}", er.ModelResource.Name); - await _executorEvents.PublishAsync(new OnResourceFailedToStartContext(cancellationToken, resourceType, er.ModelResource, er.DcpResource.Metadata.Name)).ConfigureAwait(false); - } + try + { + await createResourceFunc(er, resourceLogger, cancellationToken).ConfigureAwait(false); + } + 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(cancellationToken, resourceType, er.ModelResource, er.DcpResource.Metadata.Name)).ConfigureAwait(false); + } + catch (Exception ex) + { + // The purpose of this catch block is to ensure that if an individual executable resource fails + // to start that it doesn't tear down the entire app host AND that we route the error to the + // appropriate replica. + resourceLogger.LogError(ex, "Failed to create resource {ResourceName}", er.ModelResource.Name); + await _executorEvents.PublishAsync(new OnResourceFailedToStartContext(cancellationToken, resourceType, er.ModelResource, er.DcpResource.Metadata.Name)).ConfigureAwait(false); } - } - catch (Exception ex) - { - // The purpose of this catch block is to ensure that if an error processing the overall - // configuration of the executable resource files. This is different to the exception handling - // block above because at this stage of processing we don't necessarily have any replicas - // yet. For example if a dependency fails to start. - resourceLogger.LogError(ex, "Failed to create resource {ResourceName}", resource.Name); - await _executorEvents.PublishAsync(new OnResourceFailedToStartContext(cancellationToken, resourceType, resource, DcpResourceName: null)).ConfigureAwait(false); } } - - var tasks = new List(); - foreach (var group in executableResources.GroupBy(e => e.ModelResource)) + catch (Exception ex) { - // Force this to be async so that blocking code does not stop other executables from being created. - tasks.Add(Task.Run(() => CreateResourceExecutablesAsyncCore(group.Key, group, cancellationToken), cancellationToken)); + // The purpose of this catch block is to ensure that if an error processing the overall + // configuration of the executable resource files. This is different to the exception handling + // block above because at this stage of processing we don't necessarily have any replicas + // yet. For example if a dependency fails to start. + resourceLogger.LogError(ex, "Failed to create resource {ResourceName}", resource.Name); + await _executorEvents.PublishAsync(new OnResourceFailedToStartContext(cancellationToken, resourceType, resource, DcpResourceName: null)).ConfigureAwait(false); } + } + + var tasks = new List(); + foreach (var group in executables.GroupBy(e => e.ModelResource)) + { + // Force this to be async so that blocking code does not stop other executables from being created. + tasks.Add(Task.Run(() => CreateResourceExecutablesAsyncCore(group.Key, group, cancellationToken), cancellationToken)); + } + + return Task.WhenAll(tasks).WaitAsync(cancellationToken); + } + + private Task CreateContainerExecutablesAsync(IEnumerable containerExecAppResources, CancellationToken cancellationToken) + => CreateSnapshotableResourcesAsync(CreateContainerExecutableAsync, containerExecAppResources, cancellationToken); + + private Task CreateExecutablesAsync(IEnumerable execAppResources, CancellationToken cancellationToken) + => CreateSnapshotableResourcesAsync(CreateExecutableAsync, execAppResources, cancellationToken); + + private async Task CreateContainerExecutableAsync(AppResource er, ILogger resourceLogger, CancellationToken cancellationToken) + { + if (er.DcpResource is not ContainerExec containerExe) + { + throw new InvalidOperationException($"Expected an {nameof(ContainerExec)} resource, but got {er.DcpResource.Kind} instead"); + } + var spec = containerExe.Spec; - return Task.WhenAll(tasks).WaitAsync(cancellationToken); + try + { + AspireEventSource.Instance.DcpContainerExecutableCreateStart(er.DcpResourceName); + await _kubernetesService.CreateAsync(containerExe, cancellationToken).ConfigureAwait(false); } finally { - AspireEventSource.Instance.DcpExecutablesCreateStop(); + AspireEventSource.Instance.DcpContainerExecutableCreateStop(er.DcpResourceName); } } @@ -1145,7 +1228,15 @@ private async Task CreateExecutableAsync(AppResource er, ILogger resourceLogger, throw new FailedToApplyEnvironmentException(); } - await _kubernetesService.CreateAsync(exe, cancellationToken).ConfigureAwait(false); + try + { + AspireEventSource.Instance.DcpExecutableCreateStart(er.DcpResourceName); + await _kubernetesService.CreateAsync(exe, cancellationToken).ConfigureAwait(false); + } + finally + { + AspireEventSource.Instance.DcpExecutableCreateStop(er.DcpResourceName); + } } private static List<(string Value, bool IsSensitive, bool AnnotationOnly)> BuildLaunchArgs(AppResource er, ExecutableSpec spec, List<(string Value, bool IsSensitive)> appHostArgs) @@ -1712,6 +1803,7 @@ public async Task StartResourceAsync(IResourceReference resourceReference, Cance await _executorEvents.PublishAsync(new OnResourceStartingContext(cancellationToken, resourceType, appResource.ModelResource, appResource.DcpResourceName)).ConfigureAwait(false); await CreateExecutableAsync(appResource, resourceLogger, cancellationToken).ConfigureAwait(false); break; + default: throw new InvalidOperationException($"Unexpected resource type: {appResource.DcpResource.GetType().FullName}"); } diff --git a/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs b/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs index 6242a8b9f41..d810e9e662e 100644 --- a/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs +++ b/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs @@ -37,7 +37,7 @@ public void EnsureDcpInstancesPopulated(IResource resource) var (name, suffix) = GetContainerName(resource); AddInstancesAnnotation(resource, [new DcpInstance(name, suffix, 0)]); } - else if (resource is ExecutableResource) + else if (resource is ExecutableResource or ContainerExecutableResource) { var (name, suffix) = GetExecutableName(resource); AddInstancesAnnotation(resource, [new DcpInstance(name, suffix, 0)]); diff --git a/src/Aspire.Hosting/Dcp/DcpResourceState.cs b/src/Aspire.Hosting/Dcp/DcpResourceState.cs index 4aec23d8b7b..42bd58d9636 100644 --- a/src/Aspire.Hosting/Dcp/DcpResourceState.cs +++ b/src/Aspire.Hosting/Dcp/DcpResourceState.cs @@ -11,6 +11,7 @@ internal sealed class DcpResourceState(Dictionary application { public readonly ConcurrentDictionary ContainersMap = []; public readonly ConcurrentDictionary ExecutablesMap = []; + public readonly ConcurrentDictionary ContainerExecsMap = []; public readonly ConcurrentDictionary ServicesMap = []; public readonly ConcurrentDictionary EndpointsMap = []; public readonly ConcurrentDictionary<(string, string), List> ResourceAssociatedServicesMap = []; diff --git a/src/Aspire.Hosting/Dcp/Model/ContainerExec.cs b/src/Aspire.Hosting/Dcp/Model/ContainerExec.cs index a7e2ffc6700..4b4692b79ce 100644 --- a/src/Aspire.Hosting/Dcp/Model/ContainerExec.cs +++ b/src/Aspire.Hosting/Dcp/Model/ContainerExec.cs @@ -117,18 +117,23 @@ public ContainerExec(ContainerExecSpec spec) : base(spec) { } /// Resource name of the ContainerExec instance /// Resource name of the Container to run the command in /// The command name to run + /// Arguments of the command to run + /// Container working directory to run the command in /// A new ContainerExec instance - public static ContainerExec Create(string name, string containerName, string command) + public static ContainerExec Create(string name, string containerName, string command, List? args = null, string? workingDirectory = null) { var containerExec = new ContainerExec(new ContainerExecSpec { ContainerName = containerName, Command = command, + Args = args, + WorkingDirectory = workingDirectory }) { Kind = Dcp.ContainerExecKind, ApiVersion = Dcp.GroupVersion.ToString() }; + containerExec.Metadata.Name = name; containerExec.Metadata.NamespaceProperty = string.Empty; diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs index 0db43cf40e2..ab4ab9d6465 100644 --- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs +++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs @@ -91,6 +91,40 @@ ContainerLifetime GetContainerLifetime() } } + public CustomResourceSnapshot ToSnapshot(ContainerExec executable, CustomResourceSnapshot previous) + { + IResource? appModelResource = null; + _ = executable.AppModelResourceName is not null && _resourceState.ApplicationModel.TryGetValue(executable.AppModelResourceName, out appModelResource); + + var state = executable.AppModelInitialState is "Hidden" ? "Hidden" : executable.Status?.State; + var environment = GetEnvironmentVariables(executable.Status?.EffectiveEnv, executable.Spec.Env); + var launchArguments = GetLaunchArgs(executable); + + var relationships = ImmutableArray.Empty; + if (appModelResource != null) + { + relationships = ApplicationModel.ResourceSnapshotBuilder.BuildRelationships(appModelResource); + } + + return previous with + { + ResourceType = KnownResourceTypes.Executable, + State = state, + ExitCode = executable.Status?.ExitCode, + Properties = previous.Properties.SetResourcePropertyRange([ + new(KnownProperties.Executable.WorkDir, executable.Spec.WorkingDirectory), + new(KnownProperties.Executable.Args, executable.Status?.EffectiveArgs ?? []) { IsSensitive = true }, + new(KnownProperties.Resource.AppArgs, launchArguments?.Args) { IsSensitive = launchArguments?.IsSensitive ?? false }, + new(KnownProperties.Resource.AppArgsSensitivity, launchArguments?.ArgsAreSensitive) { IsSensitive = launchArguments?.IsSensitive ?? false }, + ]), + EnvironmentVariables = environment, + CreationTimeStamp = executable.Metadata.CreationTimestamp?.ToUniversalTime(), + StartTimeStamp = executable.Status?.StartupTimestamp?.ToUniversalTime(), + StopTimeStamp = executable.Status?.FinishTimestamp?.ToUniversalTime(), + Relationships = relationships + }; + } + public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSnapshot previous) { string? projectPath = null; diff --git a/src/Aspire.Hosting/Exec/ExecResourceManager.cs b/src/Aspire.Hosting/Exec/ExecResourceManager.cs index 476caf3f3d0..e99fd941613 100644 --- a/src/Aspire.Hosting/Exec/ExecResourceManager.cs +++ b/src/Aspire.Hosting/Exec/ExecResourceManager.cs @@ -187,12 +187,13 @@ IResource BuildResource(IResource targetExecResource) { return targetExecResource switch { - ProjectResource prj => BuildAgainstProjectResource(prj), + ProjectResource prj => BuildAgainstResource(prj), + ContainerResource container => BuildAgainstResource(container), _ => throw new InvalidOperationException($"Target resource {targetExecResource.Name} does not support exec mode.") }; } - private IResource BuildAgainstProjectResource(ProjectResource project) + private IResource BuildAgainstResource(ProjectResource project) { var projectMetadata = project.GetProjectMetadata(); var projectDir = Path.GetDirectoryName(projectMetadata.ProjectPath) ?? throw new InvalidOperationException("Project path is invalid."); @@ -238,4 +239,33 @@ annotation is EnvironmentAnnotation or EnvironmentCallbackAnnotation return CommandLineArgsParser.ParseCommand(commandUnwrapped); } } + + private IResource BuildAgainstResource(ContainerResource container) + { + var (exe, args) = ParseCommand(); + string execResourceName = container.Name + "-exec"; + + // we cant resolve dcp name of container resource here - too early in the startup pipeline + // it will be resolved later in the Dcp layer + var containerExecutable = new ContainerExecutableResource(execResourceName, container, exe, workingDirectory: null) + { + Args = args + }; + + containerExecutable.Annotations.Add(new WaitAnnotation(container, waitType: WaitType.WaitUntilHealthy)); + + _logger.LogDebug("Exec container resource '{ResourceName}' will run command '{Command}' with {ArgsCount} args '{Args}'.", execResourceName, exe, args?.Length ?? 0, string.Join(' ', args ?? [])); + return containerExecutable; + + (string exe, string[] args) ParseCommand() + { + // cli wraps the command into the string with quotes + // to keep the command as a single argument + var command = _execOptions.Command; + var commandUnwrapped = command.AsSpan(1, command.Length - 2).ToString(); + Debug.Assert(command[0] == '"' && command[^1] == '"'); + + return CommandLineArgsParser.ParseCommand(commandUnwrapped); + } + } } diff --git a/src/Shared/Model/KnownResourceTypes.cs b/src/Shared/Model/KnownResourceTypes.cs index b874daf833e..3901f470824 100644 --- a/src/Shared/Model/KnownResourceTypes.cs +++ b/src/Shared/Model/KnownResourceTypes.cs @@ -6,6 +6,7 @@ namespace Aspire.Dashboard.Model; internal static class KnownResourceTypes { public const string Executable = "Executable"; + public const string ContainerExec = "ContainerExec"; public const string Project = "Project"; public const string Container = "Container"; public const string Parameter = "Parameter"; diff --git a/tests/Aspire.Hosting.Tests/Backchannel/Exec/ContainerResourceExecTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/Exec/ContainerResourceExecTests.cs new file mode 100644 index 00000000000..3dc799f8937 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Backchannel/Exec/ContainerResourceExecTests.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Testing; +using Aspire.TestUtilities; + +namespace Aspire.Hosting.Tests.Backchannel.Exec; + +public class ContainerResourceExecTests : ExecTestsBase +{ + public ContainerResourceExecTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + [Fact] + [RequiresDocker] + public async Task Exec_NginxContainer_ListFiles_ProducesLogs_Success() + { + string[] args = [ + "--operation", "run", + "--resource", "test", + "--command", "\"ls\"", + ]; + + using var builder = PrepareBuilder(args); + WithContainerResource(builder); + + using var app = builder.Build(); + + var logs = await ExecWithLogCollectionAsync(app); + AssertLogsContain(logs, + "bin", "boot", "dev", // typical output of `ls` in a container + "Aspire exec exit code: 0" // exit code is submitted separately from the command logs + ); + + await app.StopAsync().WaitAsync(TimeSpan.FromSeconds(60)); + } + + private static void WithContainerResource(IDistributedApplicationTestingBuilder builder, string name = "test") + { + builder.AddResource(new TestContainerResource(name)) + .WithInitialState(new() + { + ResourceType = "TestProjectResource", + State = new("Running", null), + Properties = [new("A", "B"), new("c", "d")], + EnvironmentVariables = [new("e", "f", true), new("g", "h", false)] + }) + .WithImage("nginx") + .WithImageTag("1.25"); + } +} + +file sealed class TestContainerResource : ContainerResource +{ + public TestContainerResource(string name) : base(name) + { + } +}