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)
+ {
+ }
+}