From 7a96637f90166a917d759325ba6158390be05b5a Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 30 Jul 2025 18:25:22 +0200 Subject: [PATCH 1/8] init --- .../ResourceCommandAnnotation.cs | 147 +-------------- .../ResourceCommandAnnotationBase.cs | 173 ++++++++++++++++++ .../ResourceContainerExecCommandAnnotation.cs | 43 +++++ src/Aspire.Hosting/Dcp/DcpExecutor.cs | 64 ++++--- src/Aspire.Hosting/Dcp/IDcpExecutor.cs | 4 + .../Exec/ContainerExecService.cs | 107 +++++++++++ .../ResourceBuilderExtensions.cs | 34 ++++ .../Backchannel/Exec/WithExecCommandTests.cs | 59 ++++++ .../Utils/TestDcpExecutor.cs | 2 + 9 files changed, 467 insertions(+), 166 deletions(-) create mode 100644 src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotationBase.cs create mode 100644 src/Aspire.Hosting/ApplicationModel/ResourceContainerExecCommandAnnotation.cs create mode 100644 src/Aspire.Hosting/Exec/ContainerExecService.cs create mode 100644 tests/Aspire.Hosting.Tests/Backchannel/Exec/WithExecCommandTests.cs diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs index fae81cce73a..573dc4e1f6d 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs @@ -9,7 +9,7 @@ namespace Aspire.Hosting.ApplicationModel; /// Represents a command annotation for a resource. /// [DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}")] -public sealed class ResourceCommandAnnotation : IResourceAnnotation +public sealed class ResourceCommandAnnotation : ResourceCommandAnnotationBase { /// /// Initializes a new instance of the class. @@ -25,34 +25,15 @@ public ResourceCommandAnnotation( string? iconName, IconVariant? iconVariant, bool isHighlighted) + : base(name, displayName, displayDescription, parameter, confirmationMessage, iconName, iconVariant, isHighlighted) { - ArgumentNullException.ThrowIfNull(name); - ArgumentNullException.ThrowIfNull(displayName); ArgumentNullException.ThrowIfNull(updateState); ArgumentNullException.ThrowIfNull(executeCommand); - Name = name; - DisplayName = displayName; UpdateState = updateState; ExecuteCommand = executeCommand; - DisplayDescription = displayDescription; - Parameter = parameter; - ConfirmationMessage = confirmationMessage; - IconName = iconName; - IconVariant = iconVariant; - IsHighlighted = isHighlighted; } - /// - /// The name of command. The name uniquely identifies the command. - /// - public string Name { get; } - - /// - /// The display name visible in UI. - /// - public string DisplayName { get; } - /// /// A callback that is used to update the command state. /// The callback is executed when the command's resource snapshot is updated. @@ -64,128 +45,4 @@ public ResourceCommandAnnotation( /// The result is used to indicate success or failure in the UI. /// public Func> ExecuteCommand { get; } - - /// - /// Optional description of the command, to be shown in the UI. - /// Could be used as a tooltip. May be localized. - /// - public string? DisplayDescription { get; } - - /// - /// Optional parameter that configures the command in some way. - /// Clients must return any value provided by the server when invoking the command. - /// - public object? Parameter { get; } - - /// - /// When a confirmation message is specified, the UI will prompt with an OK/Cancel dialog - /// and the confirmation message before starting the command. - /// - public string? ConfirmationMessage { get; } - - /// - /// The icon name for the command. The name should be a valid FluentUI icon name. https://aka.ms/fluentui-system-icons - /// - public string? IconName { get; } - - /// - /// The icon variant for the command. - /// - public IconVariant? IconVariant { get; } - - /// - /// A flag indicating whether the command is highlighted in the UI. - /// - public bool IsHighlighted { get; } -} - -/// -/// The icon variant. -/// -public enum IconVariant -{ - /// - /// Regular variant of icons. - /// - Regular, - /// - /// Filled variant of icons. - /// - Filled -} - -/// -/// A factory for . -/// -public static class CommandResults -{ - /// - /// Produces a success result. - /// - public static ExecuteCommandResult Success() => new() { Success = true }; - - /// - /// Produces an unsuccessful result with an error message. - /// - /// An optional error message. - public static ExecuteCommandResult Failure(string? errorMessage = null) => new() { Success = false, ErrorMessage = errorMessage }; - - /// - /// Produces an unsuccessful result from an . is used as the error message. - /// - /// The exception to get the error message from. - public static ExecuteCommandResult Failure(Exception exception) => Failure(exception.Message); -} - -/// -/// The result of executing a command. Returned from . -/// -public sealed class ExecuteCommandResult -{ - /// - /// A flag that indicates whether the command was successful. - /// - public required bool Success { get; init; } - - /// - /// An optional error message that can be set when the command is unsuccessful. - /// - public string? ErrorMessage { get; init; } -} - -/// -/// Context for . -/// -public sealed class UpdateCommandStateContext -{ - /// - /// The resource snapshot. - /// - public required CustomResourceSnapshot ResourceSnapshot { get; init; } - - /// - /// The service provider. - /// - public required IServiceProvider ServiceProvider { get; init; } -} - -/// -/// Context for . -/// -public sealed class ExecuteCommandContext -{ - /// - /// The service provider. - /// - public required IServiceProvider ServiceProvider { get; init; } - - /// - /// The resource name. - /// - public required string ResourceName { get; init; } - - /// - /// The cancellation token. - /// - public required CancellationToken CancellationToken { get; init; } } diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotationBase.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotationBase.cs new file mode 100644 index 00000000000..539fa7054a5 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotationBase.cs @@ -0,0 +1,173 @@ +// 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 abstract class ResourceCommandAnnotationBase : IResourceAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + public ResourceCommandAnnotationBase( + string name, + string displayName, + string? displayDescription, + object? parameter, + string? confirmationMessage, + string? iconName, + IconVariant? iconVariant, + bool isHighlighted) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(displayName); + + Name = name; + DisplayName = displayName; + DisplayDescription = displayDescription; + Parameter = parameter; + ConfirmationMessage = confirmationMessage; + IconName = iconName; + IconVariant = iconVariant; + IsHighlighted = isHighlighted; + } + + /// + /// The name of command. The name uniquely identifies the command. + /// + public string Name { get; } + + /// + /// The display name visible in UI. + /// + public string DisplayName { get; } + + /// + /// Optional description of the command, to be shown in the UI. + /// Could be used as a tooltip. May be localized. + /// + public string? DisplayDescription { get; } + + /// + /// Optional parameter that configures the command in some way. + /// Clients must return any value provided by the server when invoking the command. + /// + public object? Parameter { get; } + + /// + /// When a confirmation message is specified, the UI will prompt with an OK/Cancel dialog + /// and the confirmation message before starting the command. + /// + public string? ConfirmationMessage { get; } + + /// + /// The icon name for the command. The name should be a valid FluentUI icon name. https://aka.ms/fluentui-system-icons + /// + public string? IconName { get; } + + /// + /// The icon variant for the command. + /// + public IconVariant? IconVariant { get; } + + /// + /// A flag indicating whether the command is highlighted in the UI. + /// + public bool IsHighlighted { get; } +} + +/// +/// The icon variant. +/// +public enum IconVariant +{ + /// + /// Regular variant of icons. + /// + Regular, + /// + /// Filled variant of icons. + /// + Filled +} + +/// +/// A factory for . +/// +public static class CommandResults +{ + /// + /// Produces a success result. + /// + public static ExecuteCommandResult Success() => new() { Success = true }; + + /// + /// Produces an unsuccessful result with an error message. + /// + /// An optional error message. + public static ExecuteCommandResult Failure(string? errorMessage = null) => new() { Success = false, ErrorMessage = errorMessage }; + + /// + /// Produces an unsuccessful result from an . is used as the error message. + /// + /// The exception to get the error message from. + public static ExecuteCommandResult Failure(Exception exception) => Failure(exception.Message); +} + +/// +/// The result of executing a command. Returned from . +/// +public sealed class ExecuteCommandResult +{ + /// + /// A flag that indicates whether the command was successful. + /// + public required bool Success { get; init; } + + /// + /// An optional error message that can be set when the command is unsuccessful. + /// + public string? ErrorMessage { get; init; } +} + +/// +/// Context for . +/// +public sealed class UpdateCommandStateContext +{ + /// + /// The resource snapshot. + /// + public required CustomResourceSnapshot ResourceSnapshot { get; init; } + + /// + /// The service provider. + /// + public required IServiceProvider ServiceProvider { get; init; } +} + +/// +/// Context for . +/// +public sealed class ExecuteCommandContext +{ + /// + /// The service provider. + /// + public required IServiceProvider ServiceProvider { get; init; } + + /// + /// The resource name. + /// + public required string ResourceName { get; init; } + + /// + /// The cancellation token. + /// + public required CancellationToken CancellationToken { get; init; } +} diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceContainerExecCommandAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceContainerExecCommandAnnotation.cs new file mode 100644 index 00000000000..ab42f9811d5 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ResourceContainerExecCommandAnnotation.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a command annotation for a resource. +/// +[DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}")] +public sealed class ResourceContainerExecCommandAnnotation : ResourceCommandAnnotationBase +{ + /// + /// Initializes a new instance of the class. + /// + public ResourceContainerExecCommandAnnotation( + string name, + string displayName, + string command, + string? workingDirectory, + string? displayDescription, + object? parameter, + string? confirmationMessage, + string? iconName, + IconVariant? iconVariant, + bool isHighlighted) + : base(name, displayName, displayDescription, parameter, confirmationMessage, iconName, iconVariant, isHighlighted) + { + Command = command; + WorkingDirectory = workingDirectory; + } + + /// + /// The command to execute in the container. + /// + public string Command { get; } + + /// + /// + /// + public string? WorkingDirectory { get; } +} diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 891e3957ac7..42a4a6333f9 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,22 @@ async Task EnsureResourceDeletedAsync(string resourceName) where T : CustomRe } } + public Task RunEphemeralResourceAsync(IResource ephemeralResource, CancellationToken cancellationToken) + { + switch (ephemeralResource) + { + case ContainerExecutableResource containerExecutableResource: + { + var appResource = PrepareContainerExecutableResource(containerExecutableResource); + // do we need to add to _resourceState or it will be added automatically while watching the k8s resource? + + return CreateContainerExecutablesAsync([appResource], cancellationToken); + } + + default: throw new InvalidOperationException($"Resource '{ephemeralResource.Name}' is not supported to run dynamically."); + } + } + 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/IDcpExecutor.cs b/src/Aspire.Hosting/Dcp/IDcpExecutor.cs index 586f971c6f7..034db4bcdc0 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,6 @@ internal interface IDcpExecutor IResourceReference GetResource(string resourceName); Task StartResourceAsync(IResourceReference resourceReference, CancellationToken cancellationToken); Task StopResourceAsync(IResourceReference resourceReference, CancellationToken cancellationToken); + + Task RunEphemeralResourceAsync(IResource ephemeralResource, CancellationToken cancellationToken); } diff --git a/src/Aspire.Hosting/Exec/ContainerExecService.cs b/src/Aspire.Hosting/Exec/ContainerExecService.cs new file mode 100644 index 00000000000..5054a28322b --- /dev/null +++ b/src/Aspire.Hosting/Exec/ContainerExecService.cs @@ -0,0 +1,107 @@ +// 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; +using Aspire.Hosting.Dcp; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Exec; + +/// +/// A service to execute container exec commands. +/// +public class ContainerExecService +{ + private readonly ResourceNotificationService _resourceNotificationService; + private readonly ResourceLoggerService _resourceLoggerService; + private readonly IDcpExecutor _dcpExecutor; + + internal ContainerExecService(ResourceNotificationService resourceNotificationService, ResourceLoggerService resourceLoggerService, IDcpExecutor dcpExecutor) + { + _resourceNotificationService = resourceNotificationService; + _resourceLoggerService = resourceLoggerService; + _dcpExecutor = dcpExecutor; + } + + /// + /// Execute a command for the specified resource. + /// + /// The resource. If the resource has multiple instances, such as replicas, then the command will be executed for each instance. + /// The command name. + /// The cancellation token. + /// The indicates command success or failure. + public async Task ExecuteCommandAsync(ContainerResource resource, string commandName, CancellationToken cancellationToken = default) + { + var names = resource.GetResolvedResourceNames(); + // Single resource for IResource. Return its result directly. + if (names.Length == 1) + { + return await ExecuteCommandCoreAsync(names[0], resource, commandName, cancellationToken).ConfigureAwait(false); + } + + throw new NotImplementedException(); + + //// Run commands for multiple resources in parallel. + //var tasks = new List>(); + //foreach (var name in names) + //{ + // tasks.Add(ExecuteCommandCoreAsync(name, resource, commandName, cancellationToken)); + //} + + //// Check for failures. + //var results = await Task.WhenAll(tasks).ConfigureAwait(false); + //var failures = new List<(string resourceId, ExecuteCommandResult result)>(); + //for (var i = 0; i < results.Length; i++) + //{ + // if (!results[i].Success) + // { + // failures.Add((names[i], results[i])); + // } + //} + + //if (failures.Count == 0) + //{ + // return new ExecuteCommandResult { Success = true }; + //} + //else + //{ + // // Aggregate error results together. + // var errorMessage = $"{failures.Count} command executions failed."; + // errorMessage += Environment.NewLine + string.Join(Environment.NewLine, failures.Select(f => $"Resource '{f.resourceId}' failed with error message: {f.result.ErrorMessage}")); + + // return new ExecuteCommandResult + // { + // Success = false, + // ErrorMessage = errorMessage + // }; + //} + } + + internal async Task ExecuteCommandCoreAsync(string resourceId, ContainerResource resource, string commandName, CancellationToken cancellationToken) + { + var logger = _resourceLoggerService.GetLogger(resourceId); + logger.LogInformation("Executing command '{CommandName}'.", commandName); + + var annotation = resource.Annotations.OfType().SingleOrDefault(a => a.Name == commandName); + if (annotation is null) + { + logger.LogInformation("Command '{CommandName}' not available.", commandName); + // return new ExecuteCommandResult { Success = false, ErrorMessage = $"Command '{commandName}' not available for resource '{resourceId}'." }; + return false; + } + + try + { + var containerExecResource = new ContainerExecutableResource(annotation.Name, resource, annotation.Command, annotation.WorkingDirectory); + + await _dcpExecutor.RunEphemeralResourceAsync(containerExecResource, cancellationToken).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Error executing command '{CommandName}'.", commandName); + // return new ExecuteCommandResult { Success = false, ErrorMessage = "Unhandled exception thrown." }; + return false; + } + } +} diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 364d0c793c9..d100e5ce96b 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -1544,6 +1544,40 @@ 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)); } + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static IResourceBuilder WithExecCommand( + this IResourceBuilder builder, + string name, + string displayName, + string command, + CommandOptions? commandOptions = null) where T : IResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(displayName); + ArgumentException.ThrowIfNullOrWhiteSpace(command); + + commandOptions ??= CommandOptions.Default; + + // Replace existing annotation with the same name. + var existingAnnotation = builder.Resource.Annotations.OfType().SingleOrDefault(a => a.Name == name); + if (existingAnnotation is not null) + { + builder.Resource.Annotations.Remove(existingAnnotation); + } + + return builder.WithAnnotation(new ResourceContainerExecCommandAnnotation(name, displayName, command, workingDirectory: null, commandOptions.Description, commandOptions.Parameter, commandOptions.ConfirmationMessage, commandOptions.IconName, commandOptions.IconVariant, commandOptions.IsHighlighted)); + } + /// /// Adds a to the resource annotations to add a resource command. /// 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..f44f37b35e8 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Backchannel/Exec/WithExecCommandTests.cs @@ -0,0 +1,59 @@ +// 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.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 Exec_NginxContainer_ListFiles_ProducesLogs_Success() + { + using var builder = PrepareBuilder([]); + var (container, containerBuilder) = WithContainerWithExecCommand(builder); + containerBuilder.WithExecCommand("list", "List files", "ls"); + + using var app = builder.Build(); + + var containerExecService = app.Services.GetRequiredService(); + + // act here + await containerExecService.ExecuteCommandAsync(container, "list", CancellationToken.None); + + await app.StopAsync().WaitAsync(TimeSpan.FromSeconds(60)); + } + + 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); + } +} + +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..76c28e63d87 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs @@ -16,4 +16,6 @@ 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) => Task.CompletedTask; } From d792a64901648fa7bfb25277d1aa5bde1dc7c83f Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 31 Jul 2025 13:42:13 +0200 Subject: [PATCH 2/8] impl? --- .../Backchannel/AppHostBackchannel.cs | 6 +- .../BackchannelJsonSerializerContext.cs | 4 +- .../ResourceContainerExecCommandAnnotation.cs | 23 ++- .../Backchannel/AppHostRpcTarget.cs | 2 +- .../Backchannel/BackchannelDataTypes.cs | 2 +- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 2 + src/Aspire.Hosting/Dcp/DcpResourceState.cs | 20 +++ .../DistributedApplicationBuilder.cs | 1 + .../Exec/ContainerExecService.cs | 147 +++++++++++------- .../Exec/ExecResourceManager.cs | 14 +- ...PublishCommandPromptingIntegrationTests.cs | 2 +- .../TestServices/TestAppHostBackchannel.cs | 4 +- .../Backchannel/Exec/ExecTestsBase.cs | 36 ++++- .../Backchannel/Exec/WithExecCommandTests.cs | 39 ++++- 14 files changed, 218 insertions(+), 84 deletions(-) diff --git a/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs index 07b4db0ca45..7264f6bdba0 100644 --- a/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs @@ -21,7 +21,7 @@ internal interface IAppHostBackchannel IAsyncEnumerable GetPublishingActivitiesAsync(CancellationToken cancellationToken); Task GetCapabilitiesAsync(CancellationToken cancellationToken); Task CompletePromptResponseAsync(string promptId, PublishingPromptInputAnswer[] answers, CancellationToken cancellationToken); - IAsyncEnumerable ExecAsync(CancellationToken cancellationToken); + IAsyncEnumerable ExecAsync(CancellationToken cancellationToken); } internal sealed class AppHostBackchannel(ILogger logger, AspireCliTelemetry telemetry) : IAppHostBackchannel @@ -194,13 +194,13 @@ await rpc.InvokeWithCancellationAsync( cancellationToken).ConfigureAwait(false); } - public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] CancellationToken cancellationToken) { using var activity = telemetry.ActivitySource.StartActivity(); var rpc = await _rpcTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); logger.LogDebug("Requesting execution."); - var commandOutputs = await rpc.InvokeWithCancellationAsync>( + var commandOutputs = await rpc.InvokeWithCancellationAsync>( "ExecAsync", Array.Empty(), cancellationToken); diff --git a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs index 5c71ee6e706..731715305dc 100644 --- a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs +++ b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs @@ -25,8 +25,8 @@ namespace Aspire.Cli.Backchannel; [JsonSerializable(typeof(IEnumerable))] [JsonSerializable(typeof(PublishingPromptInputAnswer[]))] [JsonSerializable(typeof(ValidationResult))] -[JsonSerializable(typeof(IAsyncEnumerable))] -[JsonSerializable(typeof(MessageFormatterEnumerableTracker.EnumeratorResults))] +[JsonSerializable(typeof(IAsyncEnumerable))] +[JsonSerializable(typeof(MessageFormatterEnumerableTracker.EnumeratorResults))] internal partial class BackchannelJsonSerializerContext : JsonSerializerContext { [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", Justification = "Using the Json source generator.")] diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceContainerExecCommandAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceContainerExecCommandAnnotation.cs index ab42f9811d5..da9f9db837c 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceContainerExecCommandAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceContainerExecCommandAnnotation.cs @@ -37,7 +37,28 @@ public ResourceContainerExecCommandAnnotation( public string Command { get; } /// - /// + /// The working directory in the container where the command will be executed. /// public string? WorkingDirectory { get; } } + +/// +/// The output of a command executed in a container. +/// +public class ContainerExecCommandOutput +{ + /// + /// The text output of the command. + /// + public required string Text { get; init; } + + /// + /// Indicates whether the output is an error message. + /// + public required bool IsErrorMessage { get; init; } + + /// + /// Represents the line number in the output where this message originated, if applicable. + /// + public int? LineNumber { get; init; } +} diff --git a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs index 9938b4c4b05..a1d8b4c2287 100644 --- a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs @@ -179,7 +179,7 @@ await resourceNotificationService.WaitForResourceHealthyAsync( } } - public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] CancellationToken cancellationToken) { var execResourceManager = serviceProvider.GetRequiredService(); var logsStream = execResourceManager.StreamExecResourceLogs(cancellationToken); diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index 7e991b9e36a..5511f744a40 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -194,7 +194,7 @@ internal class BackchannelLogEntry public required string CategoryName { get; set; } } -internal class CommandOutput +internal class BackchannelCommandOutput { public required string Text { get; init; } public bool IsErrorMessage { get; init; } diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 42a4a6333f9..6c115d90d11 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -1890,7 +1890,9 @@ public Task RunEphemeralResourceAsync(IResource ephemeralResource, CancellationT case ContainerExecutableResource containerExecutableResource: { var appResource = PrepareContainerExecutableResource(containerExecutableResource); + // do we need to add to _resourceState or it will be added automatically while watching the k8s resource? + _resourceState.AddResource(appResource); return CreateContainerExecutablesAsync([appResource], cancellationToken); } diff --git a/src/Aspire.Hosting/Dcp/DcpResourceState.cs b/src/Aspire.Hosting/Dcp/DcpResourceState.cs index 42bd58d9636..57afe090209 100644 --- a/src/Aspire.Hosting/Dcp/DcpResourceState.cs +++ b/src/Aspire.Hosting/Dcp/DcpResourceState.cs @@ -4,6 +4,8 @@ using System.Collections.Concurrent; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp.Model; +//using System.Collections.Generic; +//using k8s.Models; namespace Aspire.Hosting.Dcp; @@ -18,4 +20,22 @@ internal sealed class DcpResourceState(Dictionary application public Dictionary ApplicationModel { get; } = applicationModel; public List AppResources { get; } = appResources; + + public void AddResource(AppResource appResource) + { + var modelResource = appResource.ModelResource; + //var dcpResource = appResource.DcpResource; + + ApplicationModel.TryAdd(modelResource.Name, modelResource); + if (!AppResources.Contains(appResource)) + { + AppResources.Add(appResource); + } + + //_ = appResource.DcpResource switch + //{ + // Container c => ContainersMap.TryAdd(dcpResource.Name(), c), + // _ => false + //}; + } } diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index e21ac05aa40..0d157e7d409 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(s => new ContainerExecService(s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); #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 index 5054a28322b..27cc5d20832 100644 --- a/src/Aspire.Hosting/Exec/ContainerExecService.cs +++ b/src/Aspire.Hosting/Exec/ContainerExecService.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 System.Runtime.CompilerServices; +using System.Threading.Tasks; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp; using Microsoft.Extensions.Logging; @@ -14,70 +16,77 @@ public class ContainerExecService { private readonly ResourceNotificationService _resourceNotificationService; private readonly ResourceLoggerService _resourceLoggerService; + private readonly IDcpExecutor _dcpExecutor; + private readonly DcpNameGenerator _dcpNameGenerator; - internal ContainerExecService(ResourceNotificationService resourceNotificationService, ResourceLoggerService resourceLoggerService, IDcpExecutor dcpExecutor) + internal 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 resource. If the resource has multiple instances, such as replicas, then the command will be executed for each instance. + /// The specific id of the resource instance. /// The command name. /// The cancellation token. /// The indicates command success or failure. - public async Task ExecuteCommandAsync(ContainerResource resource, string commandName, CancellationToken cancellationToken = default) + public async IAsyncEnumerable ExecuteCommandAsync(string resourceId, string commandName, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - var names = resource.GetResolvedResourceNames(); - // Single resource for IResource. Return its result directly. - if (names.Length == 1) + if (!_resourceNotificationService.TryGetCurrentState(resourceId, out var resourceEvent)) + { + yield return new() + { + Text = $"Resource '{resourceId}' not found.", + IsErrorMessage = true + }; + yield break; + } + + if (resourceEvent.Resource is not ContainerResource containerResource) { - return await ExecuteCommandCoreAsync(names[0], resource, commandName, cancellationToken).ConfigureAwait(false); + yield return new() + { + Text = $"Resource '{resourceId}' is not a container resource.", + IsErrorMessage = true + }; + yield break; } - throw new NotImplementedException(); - - //// Run commands for multiple resources in parallel. - //var tasks = new List>(); - //foreach (var name in names) - //{ - // tasks.Add(ExecuteCommandCoreAsync(name, resource, commandName, cancellationToken)); - //} - - //// Check for failures. - //var results = await Task.WhenAll(tasks).ConfigureAwait(false); - //var failures = new List<(string resourceId, ExecuteCommandResult result)>(); - //for (var i = 0; i < results.Length; i++) - //{ - // if (!results[i].Success) - // { - // failures.Add((names[i], results[i])); - // } - //} - - //if (failures.Count == 0) - //{ - // return new ExecuteCommandResult { Success = true }; - //} - //else - //{ - // // Aggregate error results together. - // var errorMessage = $"{failures.Count} command executions failed."; - // errorMessage += Environment.NewLine + string.Join(Environment.NewLine, failures.Select(f => $"Resource '{f.resourceId}' failed with error message: {f.result.ErrorMessage}")); - - // return new ExecuteCommandResult - // { - // Success = false, - // ErrorMessage = errorMessage - // }; - //} + var outputLogs = ExecuteCommandCoreAsync(resourceEvent.ResourceId, containerResource, commandName, cancellationToken); + await foreach (var output in outputLogs.WithCancellation(cancellationToken)) + { + yield return output; + } } - internal async Task ExecuteCommandCoreAsync(string resourceId, ContainerResource resource, string commandName, CancellationToken cancellationToken) + /// + /// Execute a command for the specified resource. + /// + /// The resource. If the resource has multiple instances, such as replicas, then the command will be executed for each instance. + /// The command name. + /// The cancellation token. + /// The indicates command success or failure. + public IAsyncEnumerable ExecuteCommandAsync(ContainerResource resource, string commandName, CancellationToken cancellationToken = default) + { + var names = resource.GetResolvedResourceNames(); + return ExecuteCommandCoreAsync(names[0], resource, commandName, cancellationToken); + } + + internal async IAsyncEnumerable ExecuteCommandCoreAsync( + string resourceId, + ContainerResource resource, + string commandName, + [EnumeratorCancellation] CancellationToken cancellationToken) { var logger = _resourceLoggerService.GetLogger(resourceId); logger.LogInformation("Executing command '{CommandName}'.", commandName); @@ -86,22 +95,50 @@ internal async Task ExecuteCommandCoreAsync(string resourceId, ContainerRe if (annotation is null) { logger.LogInformation("Command '{CommandName}' not available.", commandName); - // return new ExecuteCommandResult { Success = false, ErrorMessage = $"Command '{commandName}' not available for resource '{resourceId}'." }; - return false; + yield return new() + { + Text = $"Command '{commandName}' not available for resource '{resourceId}'.", + IsErrorMessage = true, + }; + + yield break; } - try + var containerExecResource = new ContainerExecutableResource(annotation.Name, resource, annotation.Command, annotation.WorkingDirectory); + _dcpNameGenerator.EnsureDcpInstancesPopulated(containerExecResource); + var dcpResourceName = containerExecResource.GetResolvedResourceName(); + + // in the background wait for the exec resource to reach terminal state. Once done we can complete logging + _ = Task.Run(async () => { - var containerExecResource = new ContainerExecutableResource(annotation.Name, resource, annotation.Command, annotation.WorkingDirectory); + await _resourceNotificationService.WaitForResourceAsync(containerExecResource.Name, targetStates: KnownResourceStates.TerminalStates, cancellationToken).ConfigureAwait(false); - await _dcpExecutor.RunEphemeralResourceAsync(containerExecResource, cancellationToken).ConfigureAwait(false); - return true; - } - catch (Exception ex) + // hack: https://github.com/dotnet/aspire/issues/10245 + // workarounds the race-condition between streaming all logs from the resource, and resource completion + await Task.Delay(1000, CancellationToken.None).ConfigureAwait(false); + + _resourceLoggerService.Complete(dcpResourceName); // complete stops the `WatchAsync` async-foreach below + }, cancellationToken); + + // start the ephemeral resource execution + var runResourceTask = _dcpExecutor.RunEphemeralResourceAsync(containerExecResource, cancellationToken); + + // subscribe to the logs of the resource + // log stream will be stopped by the background "completion awaiting" task + await foreach (var logs in _resourceLoggerService.WatchAsync(dcpResourceName).WithCancellation(cancellationToken).ConfigureAwait(false)) { - logger.LogError(ex, "Error executing command '{CommandName}'.", commandName); - // return new ExecuteCommandResult { Success = false, ErrorMessage = "Unhandled exception thrown." }; - return false; + foreach (var log in logs) + { + yield return new ContainerExecCommandOutput + { + Text = log.Content, + IsErrorMessage = log.IsErrorMessage, + LineNumber = log.LineNumber + }; + } } + + // wait for the resource to complete execution + await runResourceTask.ConfigureAwait(false); } } diff --git a/src/Aspire.Hosting/Exec/ExecResourceManager.cs b/src/Aspire.Hosting/Exec/ExecResourceManager.cs index e99fd941613..4ea59d1b1ca 100644 --- a/src/Aspire.Hosting/Exec/ExecResourceManager.cs +++ b/src/Aspire.Hosting/Exec/ExecResourceManager.cs @@ -37,7 +37,7 @@ public ExecResourceManager( _resourceNotificationService = resourceNotificationService ?? throw new ArgumentNullException(nameof(resourceNotificationService)); } - public async IAsyncEnumerable StreamExecResourceLogs([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable StreamExecResourceLogs([EnumeratorCancellation] CancellationToken cancellationToken) { if (!_execOptions.Enabled) { @@ -46,7 +46,7 @@ public async IAsyncEnumerable StreamExecResourceLogs([EnumeratorC string type = "waiting"; - yield return new CommandOutput + yield return new BackchannelCommandOutput { Text = $"Waiting for resources to be initialized...", Type = type @@ -73,7 +73,7 @@ public async IAsyncEnumerable StreamExecResourceLogs([EnumeratorC if (execResourceInitializationException is not null) { - yield return new CommandOutput + yield return new BackchannelCommandOutput { Text = execResourceInitializationException.Message, IsErrorMessage = true, @@ -85,7 +85,7 @@ public async IAsyncEnumerable StreamExecResourceLogs([EnumeratorC // dcp annotation is populated by other handler of BeforeStartEvent var dcpExecResourceName = execResource!.GetResolvedResourceName(); - yield return new CommandOutput + yield return new BackchannelCommandOutput { Text = $"Aspire exec starting...", Type = type @@ -114,7 +114,7 @@ public async IAsyncEnumerable StreamExecResourceLogs([EnumeratorC { foreach (var log in logs) { - yield return new CommandOutput + yield return new BackchannelCommandOutput { Text = log.Content, IsErrorMessage = log.IsErrorMessage, @@ -132,7 +132,7 @@ public async IAsyncEnumerable StreamExecResourceLogs([EnumeratorC int? exitCode; if ((exitCode = resourceEvent?.Snapshot?.ExitCode) is not null) { - yield return new CommandOutput + yield return new BackchannelCommandOutput { Text = "Aspire exec exit code: " + exitCode.Value, IsErrorMessage = false, @@ -143,7 +143,7 @@ public async IAsyncEnumerable StreamExecResourceLogs([EnumeratorC if (resourceEvent?.Snapshot.State == KnownResourceStates.FailedToStart) { - yield return new CommandOutput + yield return new BackchannelCommandOutput { Text = "Aspire exec failed to start", IsErrorMessage = true, diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 1a46b868641..e30af748d2e 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -643,7 +643,7 @@ public async IAsyncEnumerable GetResourceStatesAsync([Enumerat public Task ConnectAsync(string socketPath, CancellationToken cancellationToken) => Task.CompletedTask; public Task GetCapabilitiesAsync(CancellationToken cancellationToken) => Task.FromResult(new[] { "baseline.v2" }); - public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] CancellationToken cancellationToken) { await Task.CompletedTask; // Suppress CS1998 yield break; diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostBackchannel.cs index 77e48bd4f27..3335b687ac5 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostBackchannel.cs @@ -237,9 +237,9 @@ public Task CompletePromptResponseAsync(string promptId, PublishingPromptInputAn return Task.CompletedTask; } - public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] CancellationToken cancellationToken) { await Task.Delay(1, cancellationToken).ConfigureAwait(false); - yield return new CommandOutput { Text = "test", IsErrorMessage = false, LineNumber = 0 }; + yield return new BackchannelCommandOutput { Text = "test", IsErrorMessage = false, LineNumber = 0 }; } } diff --git a/tests/Aspire.Hosting.Tests/Backchannel/Exec/ExecTestsBase.cs b/tests/Aspire.Hosting.Tests/Backchannel/Exec/ExecTestsBase.cs index b8bfc11580e..89d82e84d89 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/Exec/ExecTestsBase.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/Exec/ExecTestsBase.cs @@ -19,7 +19,7 @@ public abstract class ExecTestsBase(ITestOutputHelper outputHelper) /// /// Also awaits the app startup. It has to be built before running this method. /// - internal async Task> ExecWithLogCollectionAsync( + internal async Task> ExecWithLogCollectionAsync( DistributedApplication app, int timeoutSec = 30) { @@ -28,7 +28,7 @@ internal async Task> ExecWithLogCollectionAsync( var appHostRpcTarget = app.Services.GetRequiredService(); var outputStream = appHostRpcTarget.ExecAsync(cts.Token); - var logs = new List(); + var logs = new List(); var startTask = app.StartAsync(cts.Token); await foreach (var message in outputStream) { @@ -43,7 +43,37 @@ internal async Task> ExecWithLogCollectionAsync( return logs; } - internal static void AssertLogsContain(List logs, params string[] expectedLogMessages) + 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.Text}"; + + logs.Add(message); + _outputHelper.WriteLine(log); + } + + return logs; + } + + 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.Text.Contains(expectedMessage)); + Assert.True(logFound, $"Expected log message '{expectedMessage}' not found in logs."); + } + } + + internal static void AssertLogsContain(List logs, params string[] expectedLogMessages) { if (expectedLogMessages.Length == 0) { diff --git a/tests/Aspire.Hosting.Tests/Backchannel/Exec/WithExecCommandTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/Exec/WithExecCommandTests.cs index f44f37b35e8..54b8118aa22 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/Exec/WithExecCommandTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/Exec/WithExecCommandTests.cs @@ -1,6 +1,7 @@ // 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; @@ -17,20 +18,21 @@ public WithExecCommandTests(ITestOutputHelper outputHelper) [Fact] [RequiresDocker] - public async Task Exec_NginxContainer_ListFiles_ProducesLogs_Success() + public async Task WithExecCommand_NginxContainer_ListFiles_ProducesLogs_Success() { - using var builder = PrepareBuilder([]); + using var builder = PrepareBuilder(["--operation", "run"]); var (container, containerBuilder) = WithContainerWithExecCommand(builder); containerBuilder.WithExecCommand("list", "List files", "ls"); - using var app = builder.Build(); - + var app = await EnsureAppStartAsync(builder); var containerExecService = app.Services.GetRequiredService(); - // act here - await containerExecService.ExecuteCommandAsync(container, "list", CancellationToken.None); - - await app.StopAsync().WaitAsync(TimeSpan.FromSeconds(60)); + // executing command on the container. We know it is running since DCP has already started. + var executionLogs = containerExecService.ExecuteCommandAsync(container, "list", CancellationToken.None); + var processedLogs = await ProcessAndCollectLogs(executionLogs); + AssertLogsContain(processedLogs, + "bin", "boot", "dev" // typical output of `ls` in a container + ); } private static (ContainerResource, IResourceBuilder) WithContainerWithExecCommand(IDistributedApplicationTestingBuilder builder, string name = "test") @@ -49,6 +51,27 @@ private static (ContainerResource, IResourceBuilder) WithCont return (containerResource, contBuilder); } + + /// + /// Starts the apphost and waits for the resources to be created. + /// + private static async Task EnsureAppStartAsync(IDistributedApplicationBuilder builder) + { + TaskCompletionSource resourcesCreated = new(); + + using 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 From 90906df013e687cf6829a2976ff24557d89d4f12 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 31 Jul 2025 14:06:47 +0200 Subject: [PATCH 3/8] cleanup resources as well --- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 22 +++++++++++++++---- src/Aspire.Hosting/Dcp/DcpResourceState.cs | 16 ++++++-------- src/Aspire.Hosting/Dcp/IDcpExecutor.cs | 3 ++- .../Exec/ContainerExecService.cs | 15 ++++++++++--- .../Backchannel/Exec/WithExecCommandTests.cs | 2 +- .../Utils/TestDcpExecutor.cs | 3 ++- 6 files changed, 42 insertions(+), 19 deletions(-) diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 6c115d90d11..191da7ca439 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -1883,7 +1883,7 @@ async Task EnsureResourceDeletedAsync(string resourceName) where T : CustomRe } } - public Task RunEphemeralResourceAsync(IResource ephemeralResource, CancellationToken cancellationToken) + public async Task RunEphemeralResourceAsync(IResource ephemeralResource, CancellationToken cancellationToken) { switch (ephemeralResource) { @@ -1891,15 +1891,29 @@ public Task RunEphemeralResourceAsync(IResource ephemeralResource, CancellationT { var appResource = PrepareContainerExecutableResource(containerExecutableResource); - // do we need to add to _resourceState or it will be added automatically while watching the k8s resource? - _resourceState.AddResource(appResource); + // we need to add it to the resource state manually, so that all infra monitoring works + _resourceState.Add(appResource); - return CreateContainerExecutablesAsync([appResource], cancellationToken); + await CreateContainerExecutablesAsync([appResource], cancellationToken).ConfigureAwait(false); + return appResource; } default: throw new InvalidOperationException($"Resource '{ephemeralResource.Name}' is not supported to run dynamically."); } } + public void DeleteEphemeralResource(AppResource? ephemeralResource) + { + if (ephemeralResource is null) + { + return; + } + + if (ephemeralResource.ModelResource is ContainerExecutableResource) + { + _resourceState.Remove(ephemeralResource); + _appResources.Remove(ephemeralResource); + } + } private async Task<(List<(string Value, bool IsSensitive)>, bool)> BuildArgsAsync(ILogger resourceLogger, IResource modelResource, CancellationToken cancellationToken) { diff --git a/src/Aspire.Hosting/Dcp/DcpResourceState.cs b/src/Aspire.Hosting/Dcp/DcpResourceState.cs index 57afe090209..bb42b897b8a 100644 --- a/src/Aspire.Hosting/Dcp/DcpResourceState.cs +++ b/src/Aspire.Hosting/Dcp/DcpResourceState.cs @@ -21,21 +21,19 @@ internal sealed class DcpResourceState(Dictionary application public Dictionary ApplicationModel { get; } = applicationModel; public List AppResources { get; } = appResources; - public void AddResource(AppResource appResource) + public void Remove(AppResource appResource) { - var modelResource = appResource.ModelResource; - //var dcpResource = appResource.DcpResource; + ApplicationModel.Remove(appResource.ModelResource.Name); + AppResources.Remove(appResource); + } + public void Add(AppResource appResource) + { + var modelResource = appResource.ModelResource; ApplicationModel.TryAdd(modelResource.Name, modelResource); if (!AppResources.Contains(appResource)) { AppResources.Add(appResource); } - - //_ = appResource.DcpResource switch - //{ - // Container c => ContainersMap.TryAdd(dcpResource.Name(), c), - // _ => false - //}; } } diff --git a/src/Aspire.Hosting/Dcp/IDcpExecutor.cs b/src/Aspire.Hosting/Dcp/IDcpExecutor.cs index 034db4bcdc0..14e11bf0677 100644 --- a/src/Aspire.Hosting/Dcp/IDcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/IDcpExecutor.cs @@ -13,5 +13,6 @@ internal interface IDcpExecutor Task StartResourceAsync(IResourceReference resourceReference, CancellationToken cancellationToken); Task StopResourceAsync(IResourceReference resourceReference, CancellationToken cancellationToken); - Task RunEphemeralResourceAsync(IResource ephemeralResource, CancellationToken cancellationToken); + Task RunEphemeralResourceAsync(IResource ephemeralResource, CancellationToken cancellationToken); + void DeleteEphemeralResource(AppResource? ephemeralResource); } diff --git a/src/Aspire.Hosting/Exec/ContainerExecService.cs b/src/Aspire.Hosting/Exec/ContainerExecService.cs index 27cc5d20832..2ba98b5389d 100644 --- a/src/Aspire.Hosting/Exec/ContainerExecService.cs +++ b/src/Aspire.Hosting/Exec/ContainerExecService.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Runtime.CompilerServices; -using System.Threading.Tasks; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp; using Microsoft.Extensions.Logging; @@ -138,7 +137,17 @@ internal async IAsyncEnumerable ExecuteCommandCoreAs } } - // wait for the resource to complete execution - await runResourceTask.ConfigureAwait(false); + AppResource? containerExecEphemeralResource = null; + try + { + // wait for the resource to complete execution + containerExecEphemeralResource = await runResourceTask.ConfigureAwait(false); + } + finally + { + // we know that resource has completed, and we have collected logs of execution (see the loop above). + // it is an ephemeral resource, so we dont want to leave it hanging in the DCP side - we need to delete it immediately here. + _dcpExecutor.DeleteEphemeralResource(containerExecEphemeralResource); + } } } diff --git a/tests/Aspire.Hosting.Tests/Backchannel/Exec/WithExecCommandTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/Exec/WithExecCommandTests.cs index 54b8118aa22..cd27d0b5b8c 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/Exec/WithExecCommandTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/Exec/WithExecCommandTests.cs @@ -59,7 +59,7 @@ private static async Task EnsureAppStartAsync(IDistribut { TaskCompletionSource resourcesCreated = new(); - using var app = builder.Build(); + var app = builder.Build(); var eventing = app.Services.GetRequiredService(); var sub = eventing.Subscribe((afterResourcesCreatedEvent, token) => { diff --git a/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs b/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs index 76c28e63d87..8b586a84082 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs @@ -17,5 +17,6 @@ internal sealed class TestDcpExecutor : IDcpExecutor public Task StopResourceAsync(IResourceReference resourceReference, CancellationToken cancellationToken) => Task.CompletedTask; - public Task RunEphemeralResourceAsync(IResource ephemeralResource, CancellationToken cancellationToken) => Task.CompletedTask; + public Task RunEphemeralResourceAsync(IResource ephemeralResource, CancellationToken cancellationToken) => throw new NotImplementedException(); + public void DeleteEphemeralResource(AppResource? ephemeralResource) { } } From 14ab65e2f60015924d3011270cb98aece59c1136 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 31 Jul 2025 15:27:29 +0200 Subject: [PATCH 4/8] cleanup ContainerExecsMap as well --- src/Aspire.Hosting/Dcp/DcpResourceState.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/Dcp/DcpResourceState.cs b/src/Aspire.Hosting/Dcp/DcpResourceState.cs index bb42b897b8a..a8eba23b903 100644 --- a/src/Aspire.Hosting/Dcp/DcpResourceState.cs +++ b/src/Aspire.Hosting/Dcp/DcpResourceState.cs @@ -4,8 +4,6 @@ using System.Collections.Concurrent; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp.Model; -//using System.Collections.Generic; -//using k8s.Models; namespace Aspire.Hosting.Dcp; @@ -25,6 +23,12 @@ 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) From fe2dfed8ad153528cdbb00a218d88f699e61ffb7 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 31 Jul 2025 15:29:17 +0200 Subject: [PATCH 5/8] address PR comments x1 --- .../ResourceContainerExecCommandAnnotation.cs | 2 +- src/Aspire.Hosting/ResourceBuilderExtensions.cs | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceContainerExecCommandAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceContainerExecCommandAnnotation.cs index da9f9db837c..a84b8031112 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceContainerExecCommandAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceContainerExecCommandAnnotation.cs @@ -12,7 +12,7 @@ namespace Aspire.Hosting.ApplicationModel; public sealed class ResourceContainerExecCommandAnnotation : ResourceCommandAnnotationBase { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// public ResourceContainerExecCommandAnnotation( string name, diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index d100e5ce96b..7a23147629a 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -1545,15 +1545,15 @@ public static IResourceBuilder WithCommand( } /// - /// + /// 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, From 7e645d9c91a2663f4bf6bd99d73287d9dad4cefb Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 31 Jul 2025 17:20:22 +0200 Subject: [PATCH 6/8] revert backchannel type rename --- src/Aspire.Cli/Backchannel/AppHostBackchannel.cs | 6 +++--- .../BackchannelJsonSerializerContext.cs | 4 ++-- src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs | 2 +- .../Backchannel/BackchannelDataTypes.cs | 2 +- src/Aspire.Hosting/Exec/ExecResourceManager.cs | 14 +++++++------- .../PublishCommandPromptingIntegrationTests.cs | 2 +- .../TestServices/TestAppHostBackchannel.cs | 4 ++-- .../Backchannel/Exec/ExecTestsBase.cs | 6 +++--- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs index edf4d45e554..7591c50ccfe 100644 --- a/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs @@ -22,7 +22,7 @@ internal interface IAppHostBackchannel IAsyncEnumerable GetPublishingActivitiesAsync(CancellationToken cancellationToken); Task GetCapabilitiesAsync(CancellationToken cancellationToken); Task CompletePromptResponseAsync(string promptId, PublishingPromptInputAnswer[] answers, CancellationToken cancellationToken); - IAsyncEnumerable ExecAsync(CancellationToken cancellationToken); + IAsyncEnumerable ExecAsync(CancellationToken cancellationToken); void AddDisconnectHandler(EventHandler onDisconnected); } @@ -196,13 +196,13 @@ await rpc.InvokeWithCancellationAsync( cancellationToken).ConfigureAwait(false); } - public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] CancellationToken cancellationToken) { using var activity = telemetry.ActivitySource.StartActivity(); var rpc = await _rpcTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); logger.LogDebug("Requesting execution."); - var commandOutputs = await rpc.InvokeWithCancellationAsync>( + var commandOutputs = await rpc.InvokeWithCancellationAsync>( "ExecAsync", Array.Empty(), cancellationToken); diff --git a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs index 4cb228be3ad..499c7b7c6bf 100644 --- a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs +++ b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs @@ -25,8 +25,8 @@ namespace Aspire.Cli.Backchannel; [JsonSerializable(typeof(IEnumerable))] [JsonSerializable(typeof(PublishingPromptInputAnswer[]))] [JsonSerializable(typeof(ValidationResult))] -[JsonSerializable(typeof(IAsyncEnumerable))] -[JsonSerializable(typeof(MessageFormatterEnumerableTracker.EnumeratorResults))] +[JsonSerializable(typeof(IAsyncEnumerable))] +[JsonSerializable(typeof(MessageFormatterEnumerableTracker.EnumeratorResults))] [JsonSerializable(typeof(EnvVar))] [JsonSerializable(typeof(List))] internal partial class BackchannelJsonSerializerContext : JsonSerializerContext diff --git a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs index a1d8b4c2287..9938b4c4b05 100644 --- a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs @@ -179,7 +179,7 @@ await resourceNotificationService.WaitForResourceHealthyAsync( } } - public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] CancellationToken cancellationToken) { var execResourceManager = serviceProvider.GetRequiredService(); var logsStream = execResourceManager.StreamExecResourceLogs(cancellationToken); diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index 5511f744a40..7e991b9e36a 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -194,7 +194,7 @@ internal class BackchannelLogEntry public required string CategoryName { get; set; } } -internal class BackchannelCommandOutput +internal class CommandOutput { public required string Text { get; init; } public bool IsErrorMessage { get; init; } diff --git a/src/Aspire.Hosting/Exec/ExecResourceManager.cs b/src/Aspire.Hosting/Exec/ExecResourceManager.cs index 4ea59d1b1ca..e99fd941613 100644 --- a/src/Aspire.Hosting/Exec/ExecResourceManager.cs +++ b/src/Aspire.Hosting/Exec/ExecResourceManager.cs @@ -37,7 +37,7 @@ public ExecResourceManager( _resourceNotificationService = resourceNotificationService ?? throw new ArgumentNullException(nameof(resourceNotificationService)); } - public async IAsyncEnumerable StreamExecResourceLogs([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable StreamExecResourceLogs([EnumeratorCancellation] CancellationToken cancellationToken) { if (!_execOptions.Enabled) { @@ -46,7 +46,7 @@ public async IAsyncEnumerable StreamExecResourceLogs([ string type = "waiting"; - yield return new BackchannelCommandOutput + yield return new CommandOutput { Text = $"Waiting for resources to be initialized...", Type = type @@ -73,7 +73,7 @@ public async IAsyncEnumerable StreamExecResourceLogs([ if (execResourceInitializationException is not null) { - yield return new BackchannelCommandOutput + yield return new CommandOutput { Text = execResourceInitializationException.Message, IsErrorMessage = true, @@ -85,7 +85,7 @@ public async IAsyncEnumerable StreamExecResourceLogs([ // dcp annotation is populated by other handler of BeforeStartEvent var dcpExecResourceName = execResource!.GetResolvedResourceName(); - yield return new BackchannelCommandOutput + yield return new CommandOutput { Text = $"Aspire exec starting...", Type = type @@ -114,7 +114,7 @@ public async IAsyncEnumerable StreamExecResourceLogs([ { foreach (var log in logs) { - yield return new BackchannelCommandOutput + yield return new CommandOutput { Text = log.Content, IsErrorMessage = log.IsErrorMessage, @@ -132,7 +132,7 @@ public async IAsyncEnumerable StreamExecResourceLogs([ int? exitCode; if ((exitCode = resourceEvent?.Snapshot?.ExitCode) is not null) { - yield return new BackchannelCommandOutput + yield return new CommandOutput { Text = "Aspire exec exit code: " + exitCode.Value, IsErrorMessage = false, @@ -143,7 +143,7 @@ public async IAsyncEnumerable StreamExecResourceLogs([ if (resourceEvent?.Snapshot.State == KnownResourceStates.FailedToStart) { - yield return new BackchannelCommandOutput + yield return new CommandOutput { Text = "Aspire exec failed to start", IsErrorMessage = true, diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 74dfd3edce4..522e56fb3bc 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -644,7 +644,7 @@ public async IAsyncEnumerable GetResourceStatesAsync([Enumerat public Task ConnectAsync(string socketPath, CancellationToken cancellationToken) => Task.CompletedTask; public Task GetCapabilitiesAsync(CancellationToken cancellationToken) => Task.FromResult(new[] { "baseline.v2" }); - public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] CancellationToken cancellationToken) { await Task.CompletedTask; // Suppress CS1998 yield break; diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostBackchannel.cs index c91e7550fa6..c6debf31a3d 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostBackchannel.cs @@ -241,10 +241,10 @@ public Task CompletePromptResponseAsync(string promptId, PublishingPromptInputAn return Task.CompletedTask; } - public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] CancellationToken cancellationToken) { await Task.Delay(1, cancellationToken).ConfigureAwait(false); - yield return new BackchannelCommandOutput { Text = "test", IsErrorMessage = false, LineNumber = 0 }; + yield return new CommandOutput { Text = "test", IsErrorMessage = false, LineNumber = 0 }; } public void AddDisconnectHandler(EventHandler onDisconnected) diff --git a/tests/Aspire.Hosting.Tests/Backchannel/Exec/ExecTestsBase.cs b/tests/Aspire.Hosting.Tests/Backchannel/Exec/ExecTestsBase.cs index 89d82e84d89..443167406e7 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/Exec/ExecTestsBase.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/Exec/ExecTestsBase.cs @@ -19,7 +19,7 @@ public abstract class ExecTestsBase(ITestOutputHelper outputHelper) /// /// Also awaits the app startup. It has to be built before running this method. /// - internal async Task> ExecWithLogCollectionAsync( + internal async Task> ExecWithLogCollectionAsync( DistributedApplication app, int timeoutSec = 30) { @@ -28,7 +28,7 @@ internal async Task> ExecWithLogCollectionAsync( var appHostRpcTarget = app.Services.GetRequiredService(); var outputStream = appHostRpcTarget.ExecAsync(cts.Token); - var logs = new List(); + var logs = new List(); var startTask = app.StartAsync(cts.Token); await foreach (var message in outputStream) { @@ -58,7 +58,7 @@ protected async Task> ProcessAndCollectLogs(IAs return logs; } - internal static void AssertLogsContain(List logs, params string[] expectedLogMessages) + internal static void AssertLogsContain(List logs, params string[] expectedLogMessages) { if (expectedLogMessages.Length == 0) { From cb38034e2335d004513649d80d883dbed6720a75 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 1 Aug 2025 10:35:13 +0200 Subject: [PATCH 7/8] address PR comments x1 --- .../ResourceCommandAnnotation.cs | 147 ++++++++++++++- .../ResourceCommandAnnotationBase.cs | 173 ------------------ .../ResourceContainerExecCommandAnnotation.cs | 64 ------- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 20 +- src/Aspire.Hosting/Dcp/IDcpExecutor.cs | 15 +- .../Exec/ContainerExecService.cs | 4 +- .../Exec/IContainerExecService.cs | 12 ++ .../ResourceBuilderExtensions.cs | 14 +- .../Utils/TestDcpExecutor.cs | 2 +- 9 files changed, 189 insertions(+), 262 deletions(-) delete mode 100644 src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotationBase.cs delete mode 100644 src/Aspire.Hosting/ApplicationModel/ResourceContainerExecCommandAnnotation.cs create mode 100644 src/Aspire.Hosting/Exec/IContainerExecService.cs diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs index 573dc4e1f6d..fae81cce73a 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs @@ -9,7 +9,7 @@ namespace Aspire.Hosting.ApplicationModel; /// Represents a command annotation for a resource. /// [DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}")] -public sealed class ResourceCommandAnnotation : ResourceCommandAnnotationBase +public sealed class ResourceCommandAnnotation : IResourceAnnotation { /// /// Initializes a new instance of the class. @@ -25,15 +25,34 @@ public ResourceCommandAnnotation( string? iconName, IconVariant? iconVariant, bool isHighlighted) - : base(name, displayName, displayDescription, parameter, confirmationMessage, iconName, iconVariant, isHighlighted) { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(displayName); ArgumentNullException.ThrowIfNull(updateState); ArgumentNullException.ThrowIfNull(executeCommand); + Name = name; + DisplayName = displayName; UpdateState = updateState; ExecuteCommand = executeCommand; + DisplayDescription = displayDescription; + Parameter = parameter; + ConfirmationMessage = confirmationMessage; + IconName = iconName; + IconVariant = iconVariant; + IsHighlighted = isHighlighted; } + /// + /// The name of command. The name uniquely identifies the command. + /// + public string Name { get; } + + /// + /// The display name visible in UI. + /// + public string DisplayName { get; } + /// /// A callback that is used to update the command state. /// The callback is executed when the command's resource snapshot is updated. @@ -45,4 +64,128 @@ public ResourceCommandAnnotation( /// The result is used to indicate success or failure in the UI. /// public Func> ExecuteCommand { get; } + + /// + /// Optional description of the command, to be shown in the UI. + /// Could be used as a tooltip. May be localized. + /// + public string? DisplayDescription { get; } + + /// + /// Optional parameter that configures the command in some way. + /// Clients must return any value provided by the server when invoking the command. + /// + public object? Parameter { get; } + + /// + /// When a confirmation message is specified, the UI will prompt with an OK/Cancel dialog + /// and the confirmation message before starting the command. + /// + public string? ConfirmationMessage { get; } + + /// + /// The icon name for the command. The name should be a valid FluentUI icon name. https://aka.ms/fluentui-system-icons + /// + public string? IconName { get; } + + /// + /// The icon variant for the command. + /// + public IconVariant? IconVariant { get; } + + /// + /// A flag indicating whether the command is highlighted in the UI. + /// + public bool IsHighlighted { get; } +} + +/// +/// The icon variant. +/// +public enum IconVariant +{ + /// + /// Regular variant of icons. + /// + Regular, + /// + /// Filled variant of icons. + /// + Filled +} + +/// +/// A factory for . +/// +public static class CommandResults +{ + /// + /// Produces a success result. + /// + public static ExecuteCommandResult Success() => new() { Success = true }; + + /// + /// Produces an unsuccessful result with an error message. + /// + /// An optional error message. + public static ExecuteCommandResult Failure(string? errorMessage = null) => new() { Success = false, ErrorMessage = errorMessage }; + + /// + /// Produces an unsuccessful result from an . is used as the error message. + /// + /// The exception to get the error message from. + public static ExecuteCommandResult Failure(Exception exception) => Failure(exception.Message); +} + +/// +/// The result of executing a command. Returned from . +/// +public sealed class ExecuteCommandResult +{ + /// + /// A flag that indicates whether the command was successful. + /// + public required bool Success { get; init; } + + /// + /// An optional error message that can be set when the command is unsuccessful. + /// + public string? ErrorMessage { get; init; } +} + +/// +/// Context for . +/// +public sealed class UpdateCommandStateContext +{ + /// + /// The resource snapshot. + /// + public required CustomResourceSnapshot ResourceSnapshot { get; init; } + + /// + /// The service provider. + /// + public required IServiceProvider ServiceProvider { get; init; } +} + +/// +/// Context for . +/// +public sealed class ExecuteCommandContext +{ + /// + /// The service provider. + /// + public required IServiceProvider ServiceProvider { get; init; } + + /// + /// The resource name. + /// + public required string ResourceName { get; init; } + + /// + /// The cancellation token. + /// + public required CancellationToken CancellationToken { get; init; } } diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotationBase.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotationBase.cs deleted file mode 100644 index 539fa7054a5..00000000000 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotationBase.cs +++ /dev/null @@ -1,173 +0,0 @@ -// 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 abstract class ResourceCommandAnnotationBase : IResourceAnnotation -{ - /// - /// Initializes a new instance of the class. - /// - public ResourceCommandAnnotationBase( - string name, - string displayName, - string? displayDescription, - object? parameter, - string? confirmationMessage, - string? iconName, - IconVariant? iconVariant, - bool isHighlighted) - { - ArgumentNullException.ThrowIfNull(name); - ArgumentNullException.ThrowIfNull(displayName); - - Name = name; - DisplayName = displayName; - DisplayDescription = displayDescription; - Parameter = parameter; - ConfirmationMessage = confirmationMessage; - IconName = iconName; - IconVariant = iconVariant; - IsHighlighted = isHighlighted; - } - - /// - /// The name of command. The name uniquely identifies the command. - /// - public string Name { get; } - - /// - /// The display name visible in UI. - /// - public string DisplayName { get; } - - /// - /// Optional description of the command, to be shown in the UI. - /// Could be used as a tooltip. May be localized. - /// - public string? DisplayDescription { get; } - - /// - /// Optional parameter that configures the command in some way. - /// Clients must return any value provided by the server when invoking the command. - /// - public object? Parameter { get; } - - /// - /// When a confirmation message is specified, the UI will prompt with an OK/Cancel dialog - /// and the confirmation message before starting the command. - /// - public string? ConfirmationMessage { get; } - - /// - /// The icon name for the command. The name should be a valid FluentUI icon name. https://aka.ms/fluentui-system-icons - /// - public string? IconName { get; } - - /// - /// The icon variant for the command. - /// - public IconVariant? IconVariant { get; } - - /// - /// A flag indicating whether the command is highlighted in the UI. - /// - public bool IsHighlighted { get; } -} - -/// -/// The icon variant. -/// -public enum IconVariant -{ - /// - /// Regular variant of icons. - /// - Regular, - /// - /// Filled variant of icons. - /// - Filled -} - -/// -/// A factory for . -/// -public static class CommandResults -{ - /// - /// Produces a success result. - /// - public static ExecuteCommandResult Success() => new() { Success = true }; - - /// - /// Produces an unsuccessful result with an error message. - /// - /// An optional error message. - public static ExecuteCommandResult Failure(string? errorMessage = null) => new() { Success = false, ErrorMessage = errorMessage }; - - /// - /// Produces an unsuccessful result from an . is used as the error message. - /// - /// The exception to get the error message from. - public static ExecuteCommandResult Failure(Exception exception) => Failure(exception.Message); -} - -/// -/// The result of executing a command. Returned from . -/// -public sealed class ExecuteCommandResult -{ - /// - /// A flag that indicates whether the command was successful. - /// - public required bool Success { get; init; } - - /// - /// An optional error message that can be set when the command is unsuccessful. - /// - public string? ErrorMessage { get; init; } -} - -/// -/// Context for . -/// -public sealed class UpdateCommandStateContext -{ - /// - /// The resource snapshot. - /// - public required CustomResourceSnapshot ResourceSnapshot { get; init; } - - /// - /// The service provider. - /// - public required IServiceProvider ServiceProvider { get; init; } -} - -/// -/// Context for . -/// -public sealed class ExecuteCommandContext -{ - /// - /// The service provider. - /// - public required IServiceProvider ServiceProvider { get; init; } - - /// - /// The resource name. - /// - public required string ResourceName { get; init; } - - /// - /// The cancellation token. - /// - public required CancellationToken CancellationToken { get; init; } -} diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceContainerExecCommandAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceContainerExecCommandAnnotation.cs deleted file mode 100644 index a84b8031112..00000000000 --- a/src/Aspire.Hosting/ApplicationModel/ResourceContainerExecCommandAnnotation.cs +++ /dev/null @@ -1,64 +0,0 @@ -// 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 ResourceContainerExecCommandAnnotation : ResourceCommandAnnotationBase -{ - /// - /// Initializes a new instance of the class. - /// - public ResourceContainerExecCommandAnnotation( - string name, - string displayName, - string command, - string? workingDirectory, - string? displayDescription, - object? parameter, - string? confirmationMessage, - string? iconName, - IconVariant? iconVariant, - bool isHighlighted) - : base(name, displayName, displayDescription, parameter, confirmationMessage, iconName, iconVariant, isHighlighted) - { - Command = command; - WorkingDirectory = workingDirectory; - } - - /// - /// The command to execute in the container. - /// - public string Command { get; } - - /// - /// The working directory in the container where the command will be executed. - /// - public string? WorkingDirectory { get; } -} - -/// -/// The output of a command executed in a container. -/// -public class ContainerExecCommandOutput -{ - /// - /// The text output of the command. - /// - public required string Text { get; init; } - - /// - /// Indicates whether the output is an error message. - /// - public required bool IsErrorMessage { get; init; } - - /// - /// Represents the line number in the output where this message originated, if applicable. - /// - public int? LineNumber { get; init; } -} diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 191da7ca439..1e39222fa27 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -1883,17 +1883,20 @@ 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; } @@ -1901,18 +1904,15 @@ public async Task RunEphemeralResourceAsync(IResource ephemeralReso default: throw new InvalidOperationException($"Resource '{ephemeralResource.Name}' is not supported to run dynamically."); } } - public void DeleteEphemeralResource(AppResource? ephemeralResource) + + /// + public Task DeleteEphemeralResourceAsync(AppResource ephemeralResource) { - if (ephemeralResource is null) - { - return; - } + _logger.LogInformation("Removing {DcpResourceName}", ephemeralResource.DcpResourceName); + _resourceState.Remove(ephemeralResource); + _appResources.Remove(ephemeralResource); - if (ephemeralResource.ModelResource is ContainerExecutableResource) - { - _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) diff --git a/src/Aspire.Hosting/Dcp/IDcpExecutor.cs b/src/Aspire.Hosting/Dcp/IDcpExecutor.cs index 14e11bf0677..35b1ccac07e 100644 --- a/src/Aspire.Hosting/Dcp/IDcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/IDcpExecutor.cs @@ -13,6 +13,19 @@ internal interface IDcpExecutor 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); - void DeleteEphemeralResource(AppResource? ephemeralResource); + + /// + /// 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/Exec/ContainerExecService.cs b/src/Aspire.Hosting/Exec/ContainerExecService.cs index 2ba98b5389d..1ed412417d2 100644 --- a/src/Aspire.Hosting/Exec/ContainerExecService.cs +++ b/src/Aspire.Hosting/Exec/ContainerExecService.cs @@ -11,7 +11,7 @@ namespace Aspire.Hosting.Exec; /// /// A service to execute container exec commands. /// -public class ContainerExecService +internal class ContainerExecService : IContainerExecService { private readonly ResourceNotificationService _resourceNotificationService; private readonly ResourceLoggerService _resourceLoggerService; @@ -19,7 +19,7 @@ public class ContainerExecService private readonly IDcpExecutor _dcpExecutor; private readonly DcpNameGenerator _dcpNameGenerator; - internal ContainerExecService( + public ContainerExecService( ResourceNotificationService resourceNotificationService, ResourceLoggerService resourceLoggerService, IDcpExecutor dcpExecutor, diff --git a/src/Aspire.Hosting/Exec/IContainerExecService.cs b/src/Aspire.Hosting/Exec/IContainerExecService.cs new file mode 100644 index 00000000000..e699284a2a9 --- /dev/null +++ b/src/Aspire.Hosting/Exec/IContainerExecService.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Exec; + +/// +/// A service to execute container exec commands. +/// +public interface IContainerExecService +{ + +} diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 7a23147629a..c6bc7089bfa 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Diagnostics.CodeAnalysis; using System.Net.Sockets; using System.Runtime.CompilerServices; @@ -1564,18 +1565,13 @@ public static IResourceBuilder WithExecCommand( ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(displayName); - ArgumentException.ThrowIfNullOrWhiteSpace(command); - commandOptions ??= CommandOptions.Default; - - // Replace existing annotation with the same name. - var existingAnnotation = builder.Resource.Annotations.OfType().SingleOrDefault(a => a.Name == name); - if (existingAnnotation is not null) + Func> executeCommand = async context => { - builder.Resource.Annotations.Remove(existingAnnotation); - } + var serviceProvider = context.ServiceProvider; + }; - return builder.WithAnnotation(new ResourceContainerExecCommandAnnotation(name, displayName, command, workingDirectory: null, commandOptions.Description, commandOptions.Parameter, commandOptions.ConfirmationMessage, commandOptions.IconName, commandOptions.IconVariant, commandOptions.IsHighlighted)); + return builder.WithCommand(name, displayName, executeCommand, commandOptions); } /// diff --git a/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs b/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs index 8b586a84082..1825de835ea 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs @@ -18,5 +18,5 @@ internal sealed class TestDcpExecutor : IDcpExecutor public Task StopResourceAsync(IResourceReference resourceReference, CancellationToken cancellationToken) => Task.CompletedTask; public Task RunEphemeralResourceAsync(IResource ephemeralResource, CancellationToken cancellationToken) => throw new NotImplementedException(); - public void DeleteEphemeralResource(AppResource? ephemeralResource) { } + public Task DeleteEphemeralResourceAsync(AppResource ephemeralResource) => Task.CompletedTask; } From 87db4da1fc4eb00a17f8b129681518f036866dfc Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 1 Aug 2025 16:56:39 +0200 Subject: [PATCH 8/8] stabilize and refactor --- .../ResourceExecCommandAnnotation.cs | 52 +++++++ .../DistributedApplicationBuilder.cs | 2 +- .../Exec/ContainerExecService.cs | 143 +++++++++--------- .../Exec/IContainerExecService.cs | 40 +++++ .../ResourceBuilderExtensions.cs | 48 +++++- .../Backchannel/Exec/ExecTestsBase.cs | 10 +- .../Backchannel/Exec/WithExecCommandTests.cs | 45 +++++- 7 files changed, 247 insertions(+), 93 deletions(-) create mode 100644 src/Aspire.Hosting/ApplicationModel/ResourceExecCommandAnnotation.cs 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/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 6f69b48524e..d37c063d9a4 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -245,7 +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(s => new ContainerExecService(s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); + _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 index 1ed412417d2..db321c0bc86 100644 --- a/src/Aspire.Hosting/Exec/ContainerExecService.cs +++ b/src/Aspire.Hosting/Exec/ContainerExecService.cs @@ -37,117 +37,110 @@ public ContainerExecService( /// /// The specific id of the resource instance. /// The command name. - /// The cancellation token. /// The indicates command success or failure. - public async IAsyncEnumerable ExecuteCommandAsync(string resourceId, string commandName, [EnumeratorCancellation] CancellationToken cancellationToken = default) + public ExecCommandRun ExecuteCommand(string resourceId, string commandName) { if (!_resourceNotificationService.TryGetCurrentState(resourceId, out var resourceEvent)) { - yield return new() + return new() { - Text = $"Resource '{resourceId}' not found.", - IsErrorMessage = true + ExecuteCommand = token => Task.FromResult(CommandResults.Failure($"Failed to get the resource {resourceId}")) }; - yield break; } - if (resourceEvent.Resource is not ContainerResource containerResource) + var resource = resourceEvent.Resource; + if (resource is not ContainerResource containerResource) { - yield return new() - { - Text = $"Resource '{resourceId}' is not a container resource.", - IsErrorMessage = true - }; - yield break; + throw new ArgumentException("Resource is not a container resource.", nameof(resourceId)); } - var outputLogs = ExecuteCommandCoreAsync(resourceEvent.ResourceId, containerResource, commandName, cancellationToken); - await foreach (var output in outputLogs.WithCancellation(cancellationToken)) + 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) { - yield return output; + return new() + { + ExecuteCommand = token => Task.FromResult(CommandResults.Failure($"Failed to get the resource {containerResource.Name}")) + }; } + + return ExecuteCommandCore(containerResource, annotation.Name, annotation.Command, annotation.WorkingDirectory); } /// - /// Execute a command for the specified resource. + /// Executes a command for the specified resource. /// - /// The resource. If the resource has multiple instances, such as replicas, then the command will be executed for each instance. - /// The command name. - /// The cancellation token. - /// The indicates command success or failure. - public IAsyncEnumerable ExecuteCommandAsync(ContainerResource resource, string commandName, CancellationToken cancellationToken = default) - { - var names = resource.GetResolvedResourceNames(); - return ExecuteCommandCoreAsync(names[0], resource, commandName, cancellationToken); - } - - internal async IAsyncEnumerable ExecuteCommandCoreAsync( - string resourceId, + /// The resource to execute a command in. + /// + /// + /// + /// + private ExecCommandRun ExecuteCommandCore( ContainerResource resource, string commandName, - [EnumeratorCancellation] CancellationToken cancellationToken) + string command, + string? workingDirectory) { - var logger = _resourceLoggerService.GetLogger(resourceId); - logger.LogInformation("Executing command '{CommandName}'.", commandName); + var resourceId = resource.GetResolvedResourceNames().First(); - var annotation = resource.Annotations.OfType().SingleOrDefault(a => a.Name == commandName); - if (annotation is null) - { - logger.LogInformation("Command '{CommandName}' not available.", commandName); - yield return new() - { - Text = $"Command '{commandName}' not available for resource '{resourceId}'.", - IsErrorMessage = true, - }; - - yield break; - } + var logger = _resourceLoggerService.GetLogger(resourceId); + logger.LogInformation("Starting command '{Command}' on resource {ResourceId}", command, resourceId); - var containerExecResource = new ContainerExecutableResource(annotation.Name, resource, annotation.Command, annotation.WorkingDirectory); + var containerExecResource = new ContainerExecutableResource(commandName, resource, command, workingDirectory); _dcpNameGenerator.EnsureDcpInstancesPopulated(containerExecResource); var dcpResourceName = containerExecResource.GetResolvedResourceName(); - // in the background wait for the exec resource to reach terminal state. Once done we can complete logging - _ = Task.Run(async () => + Func> commandResultTask = async (CancellationToken cancellationToken) => { + await _dcpExecutor.RunEphemeralResourceAsync(containerExecResource, cancellationToken).ConfigureAwait(false); await _resourceNotificationService.WaitForResourceAsync(containerExecResource.Name, targetStates: KnownResourceStates.TerminalStates, cancellationToken).ConfigureAwait(false); - // hack: https://github.com/dotnet/aspire/issues/10245 - // workarounds the race-condition between streaming all logs from the resource, and resource completion - await Task.Delay(1000, CancellationToken.None).ConfigureAwait(false); + if (!_resourceNotificationService.TryGetCurrentState(dcpResourceName, out var resourceEvent)) + { + return CommandResults.Failure("Failed to fetch command results."); + } - _resourceLoggerService.Complete(dcpResourceName); // complete stops the `WatchAsync` async-foreach below - }, cancellationToken); + // resource completed execution, so we can complete the log stream + _resourceLoggerService.Complete(dcpResourceName); - // start the ephemeral resource execution - var runResourceTask = _dcpExecutor.RunEphemeralResourceAsync(containerExecResource, cancellationToken); + 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}."); + }; - // subscribe to the logs of the resource - // log stream will be stopped by the background "completion awaiting" task - await foreach (var logs in _resourceLoggerService.WatchAsync(dcpResourceName).WithCancellation(cancellationToken).ConfigureAwait(false)) + return new ExecCommandRun { - foreach (var log in logs) - { - yield return new ContainerExecCommandOutput - { - Text = log.Content, - IsErrorMessage = log.IsErrorMessage, - LineNumber = log.LineNumber - }; - } - } + ExecuteCommand = commandResultTask, + GetOutputStream = token => GetResourceLogsStreamAsync(dcpResourceName, token) + }; + } - AppResource? containerExecEphemeralResource = null; - try + 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 { - // wait for the resource to complete execution - containerExecEphemeralResource = await runResourceTask.ConfigureAwait(false); + // resource is still running, so we can stream the logs as they come in. + source = _resourceLoggerService.WatchAsync(dcpResourceName); } - finally + + await foreach (var batch in source.WithCancellation(cancellationToken).ConfigureAwait(false)) { - // we know that resource has completed, and we have collected logs of execution (see the loop above). - // it is an ephemeral resource, so we dont want to leave it hanging in the DCP side - we need to delete it immediately here. - _dcpExecutor.DeleteEphemeralResource(containerExecEphemeralResource); + foreach (var logLine in batch) + { + yield return logLine; + } } } } diff --git a/src/Aspire.Hosting/Exec/IContainerExecService.cs b/src/Aspire.Hosting/Exec/IContainerExecService.cs index e699284a2a9..a8fb312083b 100644 --- a/src/Aspire.Hosting/Exec/IContainerExecService.cs +++ b/src/Aspire.Hosting/Exec/IContainerExecService.cs @@ -1,6 +1,9 @@ // 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; /// @@ -8,5 +11,42 @@ namespace Aspire.Hosting.Exec; /// 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 c6bc7089bfa..f5c96c47875 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Diagnostics.CodeAnalysis; using System.Net.Sockets; using System.Runtime.CompilerServices; @@ -1553,6 +1552,7 @@ public static IResourceBuilder WithCommand( /// 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( @@ -1560,20 +1560,52 @@ public static IResourceBuilder WithExecCommand( string name, string displayName, string command, - CommandOptions? commandOptions = null) where T : IResource + string? workingDirectory = null, + CommandOptions? commandOptions = null) where T : ContainerResource { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(displayName); - Func> executeCommand = async context => - { - var serviceProvider = context.ServiceProvider; - }; - - return builder.WithCommand(name, displayName, executeCommand, commandOptions); + 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 443167406e7..3bbd36d6d0b 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/Exec/ExecTestsBase.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/Exec/ExecTestsBase.cs @@ -43,13 +43,13 @@ internal async Task> ExecWithLogCollectionAsync( return logs; } - protected async Task> ProcessAndCollectLogs(IAsyncEnumerable containerExecLogs) + protected async Task> ProcessAndCollectLogs(IAsyncEnumerable containerExecLogs) { - var logs = new List(); + 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.Text}"; + var log = $"Received output: #{message.LineNumber} [level={logLevel}] {message.Content}"; logs.Add(message); _outputHelper.WriteLine(log); @@ -73,7 +73,7 @@ internal static void AssertLogsContain(List logs, params string[] } } - internal static void AssertLogsContain(List logs, params string[] expectedLogMessages) + internal static void AssertLogsContain(List logs, params string[] expectedLogMessages) { if (expectedLogMessages.Length == 0) { @@ -83,7 +83,7 @@ internal static void AssertLogsContain(List logs, pa foreach (var expectedMessage in expectedLogMessages) { - var logFound = logs.Any(x => x.Text.Contains(expectedMessage)); + var logFound = logs.Any(x => x.Content.Contains(expectedMessage)); Assert.True(logFound, $"Expected log message '{expectedMessage}' not found in logs."); } } diff --git a/tests/Aspire.Hosting.Tests/Backchannel/Exec/WithExecCommandTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/Exec/WithExecCommandTests.cs index cd27d0b5b8c..3f18a6aa649 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/Exec/WithExecCommandTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/Exec/WithExecCommandTests.cs @@ -18,18 +18,55 @@ public WithExecCommandTests(ITestOutputHelper outputHelper) [Fact] [RequiresDocker] - public async Task WithExecCommand_NginxContainer_ListFiles_ProducesLogs_Success() + 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 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 executionLogs = containerExecService.ExecuteCommandAsync(container, "list", CancellationToken.None); - var processedLogs = await ProcessAndCollectLogs(executionLogs); + 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 );