diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExecCommandAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExecCommandAnnotation.cs
new file mode 100644
index 00000000000..990f6c29ae4
--- /dev/null
+++ b/src/Aspire.Hosting/ApplicationModel/ResourceExecCommandAnnotation.cs
@@ -0,0 +1,52 @@
+// 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;
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents a command annotation for a resource.
+///
+[DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}")]
+public sealed class ResourceExecCommandAnnotation : IResourceAnnotation
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ResourceExecCommandAnnotation(
+ string name,
+ string displayName,
+ string command,
+ string? workingDirectory)
+ {
+ ArgumentNullException.ThrowIfNull(name);
+ ArgumentNullException.ThrowIfNull(displayName);
+ ArgumentNullException.ThrowIfNull(command);
+
+ Name = name;
+ DisplayName = displayName;
+ Command = command;
+ WorkingDirectory = workingDirectory;
+ }
+
+ ///
+ /// The name of the command.
+ ///
+ public string Name { get; }
+
+ ///
+ /// The display name of the command.
+ ///
+ public string DisplayName { get; }
+
+ ///
+ /// The command to execute.
+ ///
+ public string Command { get; }
+
+ ///
+ /// The working directory in which the command will be executed.
+ ///
+ public string? WorkingDirectory { get; set; }
+}
diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs
index 891e3957ac7..1e39222fa27 100644
--- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs
+++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs
@@ -919,30 +919,36 @@ 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);
+ PrepareContainerExecutableResource(containerExecutable);
}
}
+ private AppResource PrepareContainerExecutableResource(ContainerExecutableResource containerExecutable)
+ {
+ 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);
+ return exeAppResource;
+ }
+
private void PreparePlainExecutables()
{
var modelExecutableResources = _model.GetExecutableResources();
@@ -1877,6 +1883,38 @@ async Task EnsureResourceDeletedAsync(string resourceName) where T : CustomRe
}
}
+ ///
+ public async Task RunEphemeralResourceAsync(IResource ephemeralResource, CancellationToken cancellationToken)
+ {
+ switch (ephemeralResource)
+ {
+ case ContainerExecutableResource containerExecutableResource:
+ {
+ // prepare adds resource to the _appResources collection
+ var appResource = PrepareContainerExecutableResource(containerExecutableResource);
+
+ // we need to add it to the resource state manually, so that all infra monitoring works
+ _resourceState.Add(appResource);
+
+ _logger.LogInformation("Starting ephemeral ContainerExec resource {DcpResourceName}", appResource.DcpResourceName);
+ await CreateContainerExecutablesAsync([appResource], cancellationToken).ConfigureAwait(false);
+ return appResource;
+ }
+
+ default: throw new InvalidOperationException($"Resource '{ephemeralResource.Name}' is not supported to run dynamically.");
+ }
+ }
+
+ ///
+ public Task DeleteEphemeralResourceAsync(AppResource ephemeralResource)
+ {
+ _logger.LogInformation("Removing {DcpResourceName}", ephemeralResource.DcpResourceName);
+ _resourceState.Remove(ephemeralResource);
+ _appResources.Remove(ephemeralResource);
+
+ return Task.CompletedTask;
+ }
+
private async Task<(List<(string Value, bool IsSensitive)>, bool)> BuildArgsAsync(ILogger resourceLogger, IResource modelResource, CancellationToken cancellationToken)
{
var failedToApplyArgs = false;
diff --git a/src/Aspire.Hosting/Dcp/DcpResourceState.cs b/src/Aspire.Hosting/Dcp/DcpResourceState.cs
index 42bd58d9636..a8eba23b903 100644
--- a/src/Aspire.Hosting/Dcp/DcpResourceState.cs
+++ b/src/Aspire.Hosting/Dcp/DcpResourceState.cs
@@ -18,4 +18,26 @@ internal sealed class DcpResourceState(Dictionary application
public Dictionary ApplicationModel { get; } = applicationModel;
public List AppResources { get; } = appResources;
+
+ public void Remove(AppResource appResource)
+ {
+ ApplicationModel.Remove(appResource.ModelResource.Name);
+ AppResources.Remove(appResource);
+
+ _ = appResource.DcpResource switch
+ {
+ ContainerExec c => ContainerExecsMap.TryRemove(c.Metadata.Name, out _),
+ _ => false
+ };
+ }
+
+ public void Add(AppResource appResource)
+ {
+ var modelResource = appResource.ModelResource;
+ ApplicationModel.TryAdd(modelResource.Name, modelResource);
+ if (!AppResources.Contains(appResource))
+ {
+ AppResources.Add(appResource);
+ }
+ }
}
diff --git a/src/Aspire.Hosting/Dcp/IDcpExecutor.cs b/src/Aspire.Hosting/Dcp/IDcpExecutor.cs
index 586f971c6f7..35b1ccac07e 100644
--- a/src/Aspire.Hosting/Dcp/IDcpExecutor.cs
+++ b/src/Aspire.Hosting/Dcp/IDcpExecutor.cs
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using Aspire.Hosting.ApplicationModel;
+
namespace Aspire.Hosting.Dcp;
internal interface IDcpExecutor
@@ -10,4 +12,20 @@ internal interface IDcpExecutor
IResourceReference GetResource(string resourceName);
Task StartResourceAsync(IResourceReference resourceReference, CancellationToken cancellationToken);
Task StopResourceAsync(IResourceReference resourceReference, CancellationToken cancellationToken);
+
+ ///
+ /// Runs a resource which did not exist at the application start time.
+ /// Adds the resource to the infra to allow monitoring via and
+ ///
+ /// The aspire model resource definition.
+ /// The token to cancel run.
+ /// The appResource containing the appHost resource and dcp resource.
+ Task RunEphemeralResourceAsync(IResource ephemeralResource, CancellationToken cancellationToken);
+
+ ///
+ /// Deletes the ephemeral resource created via .
+ /// It's up to the caller to ensure that the resource has finished and is will not be used anymore.
+ ///
+ /// The resource to delete.
+ Task DeleteEphemeralResourceAsync(AppResource ephemeralResource);
}
diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs
index d87b3515d00..d37c063d9a4 100644
--- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs
+++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs
@@ -245,6 +245,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)
_innerBuilder.Services.AddSingleton();
_innerBuilder.Services.AddSingleton();
_innerBuilder.Services.AddSingleton(s => new ResourceCommandService(s.GetRequiredService(), s.GetRequiredService(), s));
+ _innerBuilder.Services.AddSingleton();
#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
_innerBuilder.Services.AddSingleton();
_innerBuilder.Services.AddSingleton(sp => sp.GetRequiredService());
diff --git a/src/Aspire.Hosting/Exec/ContainerExecService.cs b/src/Aspire.Hosting/Exec/ContainerExecService.cs
new file mode 100644
index 00000000000..db321c0bc86
--- /dev/null
+++ b/src/Aspire.Hosting/Exec/ContainerExecService.cs
@@ -0,0 +1,146 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Runtime.CompilerServices;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Dcp;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting.Exec;
+
+///
+/// A service to execute container exec commands.
+///
+internal class ContainerExecService : IContainerExecService
+{
+ private readonly ResourceNotificationService _resourceNotificationService;
+ private readonly ResourceLoggerService _resourceLoggerService;
+
+ private readonly IDcpExecutor _dcpExecutor;
+ private readonly DcpNameGenerator _dcpNameGenerator;
+
+ public ContainerExecService(
+ ResourceNotificationService resourceNotificationService,
+ ResourceLoggerService resourceLoggerService,
+ IDcpExecutor dcpExecutor,
+ DcpNameGenerator dcpNameGenerator)
+ {
+ _resourceNotificationService = resourceNotificationService;
+ _resourceLoggerService = resourceLoggerService;
+
+ _dcpExecutor = dcpExecutor;
+ _dcpNameGenerator = dcpNameGenerator;
+ }
+
+ ///
+ /// Execute a command for the specified resource.
+ ///
+ /// The specific id of the resource instance.
+ /// The command name.
+ /// The indicates command success or failure.
+ public ExecCommandRun ExecuteCommand(string resourceId, string commandName)
+ {
+ if (!_resourceNotificationService.TryGetCurrentState(resourceId, out var resourceEvent))
+ {
+ return new()
+ {
+ ExecuteCommand = token => Task.FromResult(CommandResults.Failure($"Failed to get the resource {resourceId}"))
+ };
+ }
+
+ var resource = resourceEvent.Resource;
+ if (resource is not ContainerResource containerResource)
+ {
+ throw new ArgumentException("Resource is not a container resource.", nameof(resourceId));
+ }
+
+ return ExecuteCommand(containerResource, commandName);
+ }
+
+ public ExecCommandRun ExecuteCommand(ContainerResource containerResource, string commandName)
+ {
+ var annotation = containerResource.Annotations.OfType().SingleOrDefault(a => a.Name == commandName);
+ if (annotation is null)
+ {
+ return new()
+ {
+ ExecuteCommand = token => Task.FromResult(CommandResults.Failure($"Failed to get the resource {containerResource.Name}"))
+ };
+ }
+
+ return ExecuteCommandCore(containerResource, annotation.Name, annotation.Command, annotation.WorkingDirectory);
+ }
+
+ ///
+ /// Executes a command for the specified resource.
+ ///
+ /// The resource to execute a command in.
+ ///
+ ///
+ ///
+ ///
+ private ExecCommandRun ExecuteCommandCore(
+ ContainerResource resource,
+ string commandName,
+ string command,
+ string? workingDirectory)
+ {
+ var resourceId = resource.GetResolvedResourceNames().First();
+
+ var logger = _resourceLoggerService.GetLogger(resourceId);
+ logger.LogInformation("Starting command '{Command}' on resource {ResourceId}", command, resourceId);
+
+ var containerExecResource = new ContainerExecutableResource(commandName, resource, command, workingDirectory);
+ _dcpNameGenerator.EnsureDcpInstancesPopulated(containerExecResource);
+ var dcpResourceName = containerExecResource.GetResolvedResourceName();
+
+ Func> commandResultTask = async (CancellationToken cancellationToken) =>
+ {
+ await _dcpExecutor.RunEphemeralResourceAsync(containerExecResource, cancellationToken).ConfigureAwait(false);
+ await _resourceNotificationService.WaitForResourceAsync(containerExecResource.Name, targetStates: KnownResourceStates.TerminalStates, cancellationToken).ConfigureAwait(false);
+
+ if (!_resourceNotificationService.TryGetCurrentState(dcpResourceName, out var resourceEvent))
+ {
+ return CommandResults.Failure("Failed to fetch command results.");
+ }
+
+ // resource completed execution, so we can complete the log stream
+ _resourceLoggerService.Complete(dcpResourceName);
+
+ var snapshot = resourceEvent.Snapshot;
+ return snapshot.ExitCode is 0
+ ? CommandResults.Success()
+ : CommandResults.Failure($"Command failed with exit code {snapshot.ExitCode}. Final state: {resourceEvent.Snapshot.State?.Text}.");
+ };
+
+ return new ExecCommandRun
+ {
+ ExecuteCommand = commandResultTask,
+ GetOutputStream = token => GetResourceLogsStreamAsync(dcpResourceName, token)
+ };
+ }
+
+ private async IAsyncEnumerable GetResourceLogsStreamAsync(string dcpResourceName, [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ IAsyncEnumerable> source;
+ if (_resourceNotificationService.TryGetCurrentState(dcpResourceName, out var resourceEvent)
+ && resourceEvent.Snapshot.ExitCode is not null)
+ {
+ // If the resource is already in a terminal state, we can just return the logs that were already collected.
+ source = _resourceLoggerService.GetAllAsync(dcpResourceName);
+ }
+ else
+ {
+ // resource is still running, so we can stream the logs as they come in.
+ source = _resourceLoggerService.WatchAsync(dcpResourceName);
+ }
+
+ await foreach (var batch in source.WithCancellation(cancellationToken).ConfigureAwait(false))
+ {
+ foreach (var logLine in batch)
+ {
+ yield return logLine;
+ }
+ }
+ }
+}
diff --git a/src/Aspire.Hosting/Exec/IContainerExecService.cs b/src/Aspire.Hosting/Exec/IContainerExecService.cs
new file mode 100644
index 00000000000..a8fb312083b
--- /dev/null
+++ b/src/Aspire.Hosting/Exec/IContainerExecService.cs
@@ -0,0 +1,52 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Runtime.CompilerServices;
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Exec;
+
+///
+/// A service to execute container exec commands.
+///
+public interface IContainerExecService
+{
+ ///
+ /// Runs the command in the container resource.
+ ///
+ /// Container resource to run a command in.
+ /// The command name to run. Should match the command name from
+ /// Returns the type representing command execution run. Allows to await on the command completion and reading execution logs.
+ ExecCommandRun ExecuteCommand(ContainerResource containerResource, string commandName);
+
+ ///
+ /// Runs the command in the container resource.
+ ///
+ /// Id of the container resource to execute command in.
+ /// The command name to run. Should match the command name from
+ /// Returns the type representing command execution run. Allows to await on the command completion and reading execution logs.
+ ExecCommandRun ExecuteCommand(string resourceId, string commandName);
+}
+
+///
+/// Represents the result of starting a ContainerExec
+///
+public class ExecCommandRun
+{
+ ///
+ /// Function that can be awaited to run the command and get its result.
+ ///
+ public required Func> ExecuteCommand { get; init; }
+
+ ///
+ /// Function that can be used to get the output stream of the command execution.
+ ///
+ public Func> GetOutputStream { get; init; } = EmptyOutput;
+
+#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
+ private static async IAsyncEnumerable EmptyOutput([EnumeratorCancellation] CancellationToken _)
+#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
+ {
+ yield break;
+ }
+}
diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs
index 364d0c793c9..f5c96c47875 100644
--- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs
+++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs
@@ -1544,6 +1544,68 @@ public static IResourceBuilder WithCommand(
return builder.WithAnnotation(new ResourceCommandAnnotation(name, displayName, commandOptions.UpdateState ?? (c => ResourceCommandState.Enabled), executeCommand, commandOptions.Description, commandOptions.Parameter, commandOptions.ConfirmationMessage, commandOptions.IconName, commandOptions.IconVariant, commandOptions.IsHighlighted));
}
+ ///
+ /// Adds an executable command to the resource builder with the specified name, display name, and command string.
+ ///
+ /// The type of the resource.
+ /// The resource builder to which the command will be added.
+ /// The unique name of the command.
+ /// The display name of the command, shown in the dashboard.
+ /// The command string to be executed.
+ /// The working directory in which the command will be executed.
+ /// Optional settings for the command, such as description and icon.
+ /// The resource builder, allowing for method chaining.
+ public static IResourceBuilder WithExecCommand(
+ this IResourceBuilder builder,
+ string name,
+ string displayName,
+ string command,
+ string? workingDirectory = null,
+ CommandOptions? commandOptions = null) where T : ContainerResource
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(name);
+ ArgumentNullException.ThrowIfNull(displayName);
+
+ return builder.WithAnnotation(new ResourceExecCommandAnnotation(name, displayName, command, workingDirectory));
+ }
+
+ /////
+ ///// Adds an executable command to the resource builder with the specified name, display name, and command string.
+ /////
+ ///// The type of the resource.
+ ///// The resource builder to which the command will be added.
+ ///// The unique name of the command.
+ ///// The display name of the command, shown in the dashboard.
+ ///// The command string to be executed.
+ ///// Optional settings for the command, such as description and icon.
+ ///// The resource builder, allowing for method chaining.
+ //public static IResourceBuilder WithExecCommand(
+ // this IResourceBuilder builder,
+ // string name,
+ // string displayName,
+ // string command,
+ // string? workingDirectory = null,
+ // CommandOptions? commandOptions = null) where T : ContainerResource
+ //{
+ // ArgumentNullException.ThrowIfNull(builder);
+ // ArgumentNullException.ThrowIfNull(name);
+ // ArgumentNullException.ThrowIfNull(displayName);
+
+ // Func> executeCommand = context =>
+ // {
+ // var serviceProvider = context.ServiceProvider;
+ // var containerExecService = serviceProvider.GetRequiredService();
+
+ // var containerResource = builder.Resource;
+
+ // var run = containerExecService.StartExecCommand(containerResource);
+ // return run.CommandResult;
+ // };
+
+ // return builder.WithCommand(name, displayName, executeCommand, commandOptions);
+ //}
+
///
/// Adds a to the resource annotations to add a resource command.
///
diff --git a/tests/Aspire.Hosting.Tests/Backchannel/Exec/ExecTestsBase.cs b/tests/Aspire.Hosting.Tests/Backchannel/Exec/ExecTestsBase.cs
index b8bfc11580e..3bbd36d6d0b 100644
--- a/tests/Aspire.Hosting.Tests/Backchannel/Exec/ExecTestsBase.cs
+++ b/tests/Aspire.Hosting.Tests/Backchannel/Exec/ExecTestsBase.cs
@@ -43,6 +43,21 @@ internal async Task> ExecWithLogCollectionAsync(
return logs;
}
+ protected async Task> ProcessAndCollectLogs(IAsyncEnumerable containerExecLogs)
+ {
+ var logs = new List();
+ await foreach (var message in containerExecLogs)
+ {
+ var logLevel = message.IsErrorMessage ? "error" : "info";
+ var log = $"Received output: #{message.LineNumber} [level={logLevel}] {message.Content}";
+
+ logs.Add(message);
+ _outputHelper.WriteLine(log);
+ }
+
+ return logs;
+ }
+
internal static void AssertLogsContain(List logs, params string[] expectedLogMessages)
{
if (expectedLogMessages.Length == 0)
@@ -58,6 +73,21 @@ internal static void AssertLogsContain(List logs, params string[]
}
}
+ internal static void AssertLogsContain(List logs, params string[] expectedLogMessages)
+ {
+ if (expectedLogMessages.Length == 0)
+ {
+ Assert.Empty(logs);
+ return;
+ }
+
+ foreach (var expectedMessage in expectedLogMessages)
+ {
+ var logFound = logs.Any(x => x.Content.Contains(expectedMessage));
+ Assert.True(logFound, $"Expected log message '{expectedMessage}' not found in logs.");
+ }
+ }
+
protected IDistributedApplicationTestingBuilder PrepareBuilder(string[] args)
{
var builder = TestDistributedApplicationBuilder.Create(_outputHelper, args).WithTestAndResourceLogging(_outputHelper);
diff --git a/tests/Aspire.Hosting.Tests/Backchannel/Exec/WithExecCommandTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/Exec/WithExecCommandTests.cs
new file mode 100644
index 00000000000..3f18a6aa649
--- /dev/null
+++ b/tests/Aspire.Hosting.Tests/Backchannel/Exec/WithExecCommandTests.cs
@@ -0,0 +1,119 @@
+// 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.Eventing;
+using Aspire.Hosting.Exec;
+using Aspire.Hosting.Testing;
+using Aspire.TestUtilities;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Aspire.Hosting.Tests.Backchannel.Exec;
+
+public class WithExecCommandTests : ExecTestsBase
+{
+ public WithExecCommandTests(ITestOutputHelper outputHelper)
+ : base(outputHelper)
+ {
+ }
+
+ [Fact]
+ [RequiresDocker]
+ public async Task WithExecCommand_NginxContainer_ListFiles_WatchLogStream_Success()
+ {
+ using var builder = PrepareBuilder(["--operation", "run"]);
+ var (container, containerBuilder) = WithContainerWithExecCommand(builder);
+ containerBuilder.WithExecCommand("list", "List files", "ls");
+
+ var app = await EnsureAppStartAsync(builder);
+ var containerExecService = app.Services.GetRequiredService();
+
+ var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+
+ // executing command on the container. We know it is running since DCP has already started.
+ var execCommandRun = containerExecService.ExecuteCommand(container, "list");
+ var runCommandTask = execCommandRun.ExecuteCommand(cancellationTokenSource.Token);
+
+ // the option here is either to execute the command, and collect logs later;
+ // or to run the command and immediately attach to the output stream. This will make
+ // the logs to be streamed in parallel with the command execution.
+ var output = execCommandRun.GetOutputStream(cancellationTokenSource.Token);
+ var processedLogs = await ProcessAndCollectLogs(output);
+
+ var result = await runCommandTask;
+ Assert.True(result.Success);
+
+ AssertLogsContain(processedLogs,
+ "bin", "boot", "dev" // typical output of `ls` in a container
+ );
+ }
+
+ [Fact]
+ [RequiresDocker]
+ public async Task WithExecCommand_NginxContainer_ListFiles_GetsAllLogs_Success()
+ {
+ using var builder = PrepareBuilder(["--operation", "run"]);
+ var (container, containerBuilder) = WithContainerWithExecCommand(builder);
+ containerBuilder.WithExecCommand("list", "List files", "ls");
+
+ var app = await EnsureAppStartAsync(builder);
+ var containerExecService = app.Services.GetRequiredService();
+
+ var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+
+ // executing command on the container. We know it is running since DCP has already started.
+ var execCommandRun = containerExecService.ExecuteCommand(container, "list");
+ var result = await execCommandRun.ExecuteCommand(cancellationTokenSource.Token);
+ Assert.True(result.Success);
+
+ var output = execCommandRun.GetOutputStream(cancellationTokenSource.Token);
+ var processedLogs = await ProcessAndCollectLogs(output);
+ AssertLogsContain(processedLogs,
+ "bin", "boot", "dev" // typical output of `ls` in a container
+ );
+ }
+
+ private static (ContainerResource, IResourceBuilder) WithContainerWithExecCommand(IDistributedApplicationTestingBuilder builder, string name = "test")
+ {
+ var containerResource = new TestContainerResource(name);
+ var contBuilder = builder.AddResource(containerResource)
+ .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");
+
+ return (containerResource, contBuilder);
+ }
+
+ ///
+ /// Starts the apphost and waits for the resources to be created.
+ ///
+ private static async Task EnsureAppStartAsync(IDistributedApplicationBuilder builder)
+ {
+ TaskCompletionSource resourcesCreated = new();
+
+ var app = builder.Build();
+ var eventing = app.Services.GetRequiredService();
+ var sub = eventing.Subscribe((afterResourcesCreatedEvent, token) =>
+ {
+ resourcesCreated.SetResult(true);
+ return Task.CompletedTask;
+ });
+
+ _ = app.RunAsync();
+ await resourcesCreated.Task;
+
+ return app;
+ }
+}
+
+file sealed class TestContainerResource : ContainerResource
+{
+ public TestContainerResource(string name) : base(name)
+ {
+ }
+}
diff --git a/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs b/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs
index c696619223a..1825de835ea 100644
--- a/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs
+++ b/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs
@@ -16,4 +16,7 @@ internal sealed class TestDcpExecutor : IDcpExecutor
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopResourceAsync(IResourceReference resourceReference, CancellationToken cancellationToken) => Task.CompletedTask;
+
+ public Task RunEphemeralResourceAsync(IResource ephemeralResource, CancellationToken cancellationToken) => throw new NotImplementedException();
+ public Task DeleteEphemeralResourceAsync(AppResource ephemeralResource) => Task.CompletedTask;
}