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; }