diff --git a/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs index 145cfa501b9..3fee2eed8ff 100644 --- a/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs @@ -20,6 +20,7 @@ internal interface IAppHostBackchannel Task ConnectAsync(string socketPath, CancellationToken cancellationToken); IAsyncEnumerable<(string Id, string StatusText, bool IsComplete, bool IsError)> GetPublishingActivitiesAsync(CancellationToken cancellationToken); Task GetCapabilitiesAsync(CancellationToken cancellationToken); + IAsyncEnumerable GetToolExecutionOutputStreamAsync(CancellationToken cancellationToken); } internal sealed class AppHostBackchannel(ILogger logger, CliRpcTarget target, AspireCliTelemetry telemetry) : IAppHostBackchannel @@ -179,4 +180,23 @@ public async Task GetCapabilitiesAsync(CancellationToken cancellationT return capabilities; } + + public async IAsyncEnumerable GetToolExecutionOutputStreamAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + using var activity = telemetry.ActivitySource.StartActivity(); + var rpc = await _rpcTaskCompletionSource.Task; + + logger.LogDebug("Requesting tool output stream."); + + var outputMessages = await rpc.InvokeWithCancellationAsync>( + "ExecuteToolAndStreamOutputAsync", + Array.Empty(), + cancellationToken); + + logger.LogDebug("Receiving tool output..."); + await foreach (var output in outputMessages.WithCancellation(cancellationToken)) + { + yield return output; + } + } } diff --git a/src/Aspire.Cli/Backchannel/CliRpcTarget.cs b/src/Aspire.Cli/Backchannel/CliRpcTarget.cs index f23d8ef0387..06b1e825bde 100644 --- a/src/Aspire.Cli/Backchannel/CliRpcTarget.cs +++ b/src/Aspire.Cli/Backchannel/CliRpcTarget.cs @@ -5,4 +5,4 @@ namespace Aspire.Cli.Backchannel; internal class CliRpcTarget { -} \ No newline at end of file +} diff --git a/src/Aspire.Cli/Backchannel/CommandOutput.cs b/src/Aspire.Cli/Backchannel/CommandOutput.cs new file mode 100644 index 00000000000..7c8df685143 --- /dev/null +++ b/src/Aspire.Cli/Backchannel/CommandOutput.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Backchannel; + +internal class CommandOutput +{ + public required string Text { get; set; } + public bool IsError { get; set; } +} diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 1026c266e6d..55e5d44aed0 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -51,6 +51,10 @@ public RunCommand(IDotNetCliRunner runner, IInteractionService interactionServic watchOption.Description = RunCommandStrings.WatchArgumentDescription; Options.Add(watchOption); + var toolParseOption = new Option("--tool", "-t"); + toolParseOption.Description = "Runs a resource as a tool."; + Options.Add(toolParseOption); + TreatUnmatchedTokensAsErrors = false; } @@ -124,19 +128,77 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var backchannelCompletitionSource = new TaskCompletionSource(); - var unmatchedTokens = parseResult.UnmatchedTokens.ToArray(); + string[] runArgs; + var tool = parseResult.GetValue("--tool"); + if (!string.IsNullOrWhiteSpace(tool)) + { + runArgs = [ + "--operation", "tool", + "--tool", tool, + ..parseResult.UnmatchedTokens + ]; + } + else + { + runArgs = parseResult.UnmatchedTokens.ToArray(); + } + // If the app host supports the backchannel we will use it to communicate with the app host. var pendingRun = _runner.RunAsync( effectiveAppHostProjectFile, watch, !watch, - unmatchedTokens, + runArgs, env, backchannelCompletitionSource, runOptions, cancellationToken); - if (useRichConsole) + if (!string.IsNullOrWhiteSpace(tool)) + { + // start and connect backchannel + var backchannel = await _interactionService.ShowStatusAsync( + ":linked_paperclips: Waiting for Aspire app host...", + async () => { + + // If we use the --wait-for-debugger option we print out the process ID + // of the apphost so that the user can attach to it. + if (waitForDebugger) + { + _interactionService.DisplayMessage("bug", $"Waiting for debugger to attach to app host process"); + } + + // The wait for the debugger in the apphost is done inside the CreateBuilder(...) method + // before the backchannel is created, therefore waiting on the backchannel is a + // good signal that the debugger was attached (or timed out). + var backchannel = await backchannelCompletitionSource.Task.WaitAsync(cancellationToken); + return backchannel; + }); + + _ = await _interactionService.ShowStatusAsync( + ":running_shoe: Running tool execution...", + async() => + { + // execute tool and stream the output + var outputStream = backchannel.GetToolExecutionOutputStreamAsync(cancellationToken); + await foreach (var output in outputStream) + { + _interactionService.WriteConsoleLog(message: output.Text, isError: output.IsError); + } + + return ExitCodeConstants.Success; + }); + + _ = await _interactionService.ShowStatusAsync( + ":chequered_flag: Shutting Aspire app host...", + async () => { + await backchannel.RequestStopAsync(cancellationToken); + return ExitCodeConstants.Success; + }); + + return await pendingRun; + } + else if (useRichConsole) { // We wait for the back channel to be created to signal that // the AppHost is ready to accept requests. @@ -275,7 +337,7 @@ await _ansiConsole.Live(rows).StartAsync(async context => } }); - var result = await pendingRun; + var result = await pendingRun; if (result != 0) { _interactionService.DisplayLines(runOutputCollector.GetLines()); diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index 44761105764..1617d454ab7 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -11,6 +11,9 @@ namespace Aspire.Cli.Interaction; internal class ConsoleInteractionService : IInteractionService { + private static readonly Style s_errorMessageStyle = new Style(foreground: Color.Red, background: null, decoration: Decoration.Bold); + private static readonly Style s_infoMessageStyle = new Style(foreground: Color.Teal, background: null, decoration: Decoration.Bold); + private readonly IAnsiConsole _ansiConsole; public ConsoleInteractionService(IAnsiConsole ansiConsole) @@ -88,6 +91,12 @@ public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, stri return ExitCodeConstants.AppHostIncompatible; } + public void WriteConsoleLog(string message, bool isError = false) + { + var style = isError ? s_errorMessageStyle : s_infoMessageStyle; + _ansiConsole.WriteLine(message, style); + } + public void DisplayError(string errorMessage) { DisplayMessage("thumbs_down", $"[red bold]{errorMessage}[/]"); diff --git a/src/Aspire.Cli/Interaction/IInteractionService.cs b/src/Aspire.Cli/Interaction/IInteractionService.cs index 33acb12333c..0f34fcaca72 100644 --- a/src/Aspire.Cli/Interaction/IInteractionService.cs +++ b/src/Aspire.Cli/Interaction/IInteractionService.cs @@ -22,4 +22,5 @@ internal interface IInteractionService void DisplayLines(IEnumerable<(string Stream, string Line)> lines); void DisplayCancellationMessage(); void DisplayEmptyLine(); + void WriteConsoleLog(string message, bool isError = false); } diff --git a/src/Aspire.Cli/Properties/launchSettings.json b/src/Aspire.Cli/Properties/launchSettings.json index b52323c38b7..b8cfa37cde3 100644 --- a/src/Aspire.Cli/Properties/launchSettings.json +++ b/src/Aspire.Cli/Properties/launchSettings.json @@ -30,5 +30,19 @@ "environmentVariables": { } }, + "run": { + "commandName": "Project", + "dotnetRunMessages": true, + "commandLineArgs": "run", + "environmentVariables": { + } + }, + "run-tool-migration-add": { + "commandName": "Project", + "dotnetRunMessages": true, + "commandLineArgs": "run --tool migration-add --add-postgres --add-migration-tool --project ../../../../../tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHost1.AppHost.csproj", + "environmentVariables": { + } + } } } diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index 76c99b9bb95..d8556a528bb 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -135,6 +135,26 @@ public static IDistributedApplicationTestingBuilder Create(string[] args, Action return new TestingBuilder(args, configureBuilder); } + /// + /// Creates a new instance of . + /// + /// The command line arguments to pass to the entry point. + /// The delegate used to configure the builder. + /// The assembly of app host + /// + /// A new instance of . + /// + public static IDistributedApplicationTestingBuilder Create( + string[] args, + Action configureBuilder, + Assembly appHostAssembly) + { + ThrowIfNullOrContainsIsNullOrEmpty(args); + ArgumentNullException.ThrowIfNull(configureBuilder); + + return new TestingBuilder(args, configureBuilder, appHostAssembly); + } + private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] args) { ArgumentNullException.ThrowIfNull(args); @@ -293,18 +313,30 @@ public async Task StopAsync(CancellationToken cancellationToken) private sealed class TestingBuilder( string[] args, - Action configureBuilder) : IDistributedApplicationTestingBuilder + Action configureBuilder, + Assembly? appHostAssembly = null) : IDistributedApplicationTestingBuilder { - private readonly DistributedApplicationBuilder _innerBuilder = CreateInnerBuilder(args, configureBuilder); + private readonly DistributedApplicationBuilder _innerBuilder = CreateInnerBuilder(args, configureBuilder, appHostAssembly); private DistributedApplication? _app; private static DistributedApplicationBuilder CreateInnerBuilder( string[] args, - Action configureBuilder) + Action configureBuilder, + Assembly? appHostAssembly = null) { var builder = TestingBuilderFactory.CreateBuilder(args, onConstructing: (applicationOptions, hostBuilderOptions) => { - DistributedApplicationFactory.ConfigureBuilder(args, applicationOptions, hostBuilderOptions, FindApplicationAssembly(), configureBuilder); + Assembly appAssembly; + if (appHostAssembly is not null && GetDcpCliPath(appHostAssembly) is { Length: > 0 }) + { + appAssembly = appHostAssembly; + } + else + { + appAssembly = FindApplicationAssembly(); + } + + DistributedApplicationFactory.ConfigureBuilder(args, applicationOptions, hostBuilderOptions, appAssembly, configureBuilder); }); if (!builder.Configuration.GetValue(KnownConfigNames.TestingDisableHttpClient, false)) diff --git a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs index 0aba9ee411d..50f13e183c7 100644 --- a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs @@ -6,6 +6,7 @@ using Aspire.Hosting.Dashboard; using Aspire.Hosting.Devcontainers.Codespaces; using Aspire.Hosting.Publishing; +using Aspire.Hosting.Tools; using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -20,9 +21,14 @@ internal class AppHostRpcTarget( IServiceProvider serviceProvider, PublishingActivityProgressReporter activityReporter, IHostApplicationLifetime lifetime, - DistributedApplicationOptions options - ) + DistributedApplicationOptions options, + ToolExecutionService toolExecutionService) { + public IAsyncEnumerable ExecuteToolAndStreamOutputAsync(CancellationToken cancellationToken) + { + return toolExecutionService.ExecuteToolAndStreamOutputAsync(cancellationToken); + } + public async IAsyncEnumerable<(string Id, string StatusText, bool IsComplete, bool IsError)> GetPublishingActivitiesAsync([EnumeratorCancellation]CancellationToken cancellationToken) { while (cancellationToken.IsCancellationRequested == false) @@ -178,4 +184,4 @@ public Task GetCapabilitiesAsync(CancellationToken cancellationToken) }); } #pragma warning restore CA1822 -} \ No newline at end of file +} diff --git a/src/Aspire.Hosting/Backchannel/BackchannelService.cs b/src/Aspire.Hosting/Backchannel/BackchannelService.cs index 77a2b600ead..56d31526563 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelService.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelService.cs @@ -11,7 +11,13 @@ namespace Aspire.Hosting.Cli; -internal sealed class BackchannelService(ILogger logger, IConfiguration configuration, AppHostRpcTarget appHostRpcTarget, IDistributedApplicationEventing eventing, IServiceProvider serviceProvider) : BackgroundService +internal sealed class BackchannelService( + ILogger logger, + IConfiguration configuration, + AppHostRpcTarget appHostRpcTarget, + IDistributedApplicationEventing eventing, + IServiceProvider serviceProvider) + : BackgroundService { private JsonRpc? _rpc; diff --git a/src/Aspire.Hosting/Backchannel/CommandOutput.cs b/src/Aspire.Hosting/Backchannel/CommandOutput.cs new file mode 100644 index 00000000000..7781d1fedd5 --- /dev/null +++ b/src/Aspire.Hosting/Backchannel/CommandOutput.cs @@ -0,0 +1,10 @@ +// 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.Backchannel; + +internal class CommandOutput +{ + public required string Text { get; set; } + public bool IsError { get; set; } +} diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index da8b754d049..afd23d930ab 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -19,6 +19,7 @@ using Aspire.Hosting.Lifecycle; using Aspire.Hosting.Orchestrator; using Aspire.Hosting.Publishing; +using Aspire.Hosting.Tools; using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -123,12 +124,13 @@ private DistributedApplicationExecutionContextOptions BuildExecutionContextOptio { "publish" => DistributedApplicationOperation.Publish, "run" => DistributedApplicationOperation.Run, + "tool" => DistributedApplicationOperation.Tool, _ => throw new DistributedApplicationException("Invalid operation specified. Valid operations are 'publish' or 'run'.") }; return operation switch { - DistributedApplicationOperation.Run => new DistributedApplicationExecutionContextOptions(operation), + DistributedApplicationOperation.Run or DistributedApplicationOperation.Tool => new DistributedApplicationExecutionContextOptions(operation), DistributedApplicationOperation.Publish => new DistributedApplicationExecutionContextOptions(operation, _innerBuilder.Configuration["Publishing:Publisher"] ?? "manifest"), _ => throw new DistributedApplicationException("Invalid operation specified. Valid operations are 'publish' or 'run'.") }; @@ -194,6 +196,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) // Set configuration ConfigurePublishingOptions(options); + ConfigureToolOptions(options); _innerBuilder.Configuration.AddInMemoryCollection(new Dictionary { // Make the app host directory available to the application via configuration @@ -334,6 +337,21 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) ConfigureDashboardHealthCheck(); } + // Devcontainers & Codespaces + _innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, ConfigureCodespacesOptions>()); + _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddHostedService(); + _innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, ConfigureDevcontainersOptions>()); + _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.TryAddLifecycleHook(); + + Eventing.Subscribe(BuiltInDistributedApplicationEventSubscriptionHandlers.InitializeDcpAnnotations); + } + + _innerBuilder.Services.AddSingleton(); + + if (ExecutionContext.IsRunMode || ExecutionContext.IsToolMode) + { if (options.EnableResourceLogging) { // This must be added before DcpHostService to ensure that it can subscribe to the ResourceNotificationService and ResourceLoggerService @@ -354,16 +372,6 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) // We need a unique path per application instance _innerBuilder.Services.AddSingleton(new Locations()); _innerBuilder.Services.AddSingleton(); - - // Devcontainers & Codespaces - _innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, ConfigureCodespacesOptions>()); - _innerBuilder.Services.AddSingleton(); - _innerBuilder.Services.AddHostedService(); - _innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, ConfigureDevcontainersOptions>()); - _innerBuilder.Services.AddSingleton(); - _innerBuilder.Services.TryAddLifecycleHook(); - - Eventing.Subscribe(BuiltInDistributedApplicationEventSubscriptionHandlers.InitializeDcpAnnotations); } // Publishing support @@ -490,6 +498,43 @@ private void ConfigurePublishingOptions(DistributedApplicationOptions options) _innerBuilder.Services.Configure(_innerBuilder.Configuration.GetSection(PublishingOptions.Publishing)); } + private void ConfigureToolOptions(DistributedApplicationOptions options) + { + var switchMappings = new Dictionary() + { + { "--operation", "AppHost:Operation" }, + { "--tool", "Tool:Resource" }, + { "--project", "Tool:Project" } + }; + _innerBuilder.Configuration.AddCommandLine(options.Args ?? [], switchMappings); + _innerBuilder.Services.Configure(_innerBuilder.Configuration.GetSection(ToolOptions.Section)); + + var filteredArgs = new List(); + var args = options.Args ?? Array.Empty(); + for (int i = 0; i < args.Length;) + { + var arg = args[i]; + if (switchMappings.ContainsKey(arg)) + { + i++; + if (i < args.Length && !args[i].StartsWith("--")) + { + i++; + } + } + else + { + filteredArgs.Add(arg); + i++; + } + } + + _innerBuilder.Services.PostConfigure(toolOptions => + { + toolOptions.Args = filteredArgs.ToArray(); + }); + } + /// public DistributedApplication Build() { diff --git a/src/Aspire.Hosting/DistributedApplicationExecutionContext.cs b/src/Aspire.Hosting/DistributedApplicationExecutionContext.cs index 7af4adc9ad8..81661d8b922 100644 --- a/src/Aspire.Hosting/DistributedApplicationExecutionContext.cs +++ b/src/Aspire.Hosting/DistributedApplicationExecutionContext.cs @@ -85,4 +85,9 @@ public IServiceProvider ServiceProvider /// Returns true if the current operation is running. /// public bool IsRunMode => Operation == DistributedApplicationOperation.Run; + + /// + /// Returns true if the current operation is tool. + /// + public bool IsToolMode => Operation == DistributedApplicationOperation.Tool; } diff --git a/src/Aspire.Hosting/DistributedApplicationOperation.cs b/src/Aspire.Hosting/DistributedApplicationOperation.cs index a3e78a6e884..e5fd16df159 100644 --- a/src/Aspire.Hosting/DistributedApplicationOperation.cs +++ b/src/Aspire.Hosting/DistributedApplicationOperation.cs @@ -16,5 +16,10 @@ public enum DistributedApplicationOperation /// /// AppHost is being run for the purpose of publishing a manifest for deployment. /// - Publish + Publish, + + /// + /// AppHost is being run for the purpose of executing a tool. + /// + Tool } diff --git a/src/Aspire.Hosting/DistributedApplicationRunner.cs b/src/Aspire.Hosting/DistributedApplicationRunner.cs index 4860cdc8686..872d2c82bca 100644 --- a/src/Aspire.Hosting/DistributedApplicationRunner.cs +++ b/src/Aspire.Hosting/DistributedApplicationRunner.cs @@ -13,7 +13,16 @@ namespace Aspire.Hosting; -internal sealed class DistributedApplicationRunner(ILogger logger, IHostApplicationLifetime lifetime, DistributedApplicationExecutionContext executionContext, DistributedApplicationModel model, IServiceProvider serviceProvider, IPublishingActivityProgressReporter activityReporter, IDistributedApplicationEventing eventing, BackchannelService backchannelService) : BackgroundService +internal sealed class DistributedApplicationRunner( + ILogger logger, + IHostApplicationLifetime lifetime, + DistributedApplicationExecutionContext executionContext, + DistributedApplicationModel model, + IServiceProvider serviceProvider, + IPublishingActivityProgressReporter activityReporter, + IDistributedApplicationEventing eventing, + BackchannelService backchannelService) + : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { diff --git a/src/Aspire.Hosting/Tools/ToolExecutionService.cs b/src/Aspire.Hosting/Tools/ToolExecutionService.cs new file mode 100644 index 00000000000..07373eaa179 --- /dev/null +++ b/src/Aspire.Hosting/Tools/ToolExecutionService.cs @@ -0,0 +1,135 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Backchannel; +using Aspire.Hosting.Dcp.Process; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Aspire.Hosting.Tools; + +internal class ToolExecutionService +{ + private readonly ToolOptions _toolOptions; + private readonly ILogger _logger; + private readonly DistributedApplicationModel _model; + + public ToolExecutionService( + IOptions toolOptions, + ILogger logger, + DistributedApplicationModel model) + { + _logger = logger; + _toolOptions = toolOptions.Value; + _model = model; + } + + public async IAsyncEnumerable ExecuteToolAndStreamOutputAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + IResource? toolResource = _model.Resources.FirstOrDefault(r => r.Name == _toolOptions.Resource); + if (toolResource is null) + { + throw new InvalidOperationException($"Tool resource '{_toolOptions.Resource}' not found in the distributed application model."); + } + if (toolResource is not ExecutableResource toolExecutableResource) + { + throw new NotSupportedException("Can't run tool which is not executable"); + } + + var commandLineArgs = await BuildCommandLineArgsAsync(toolResource).ConfigureAwait(false); + + // Channel now carries (string Data, bool IsError) + var outputChannel = Channel.CreateUnbounded<(string Data, bool IsError)>(); + var sendingTasks = new ConcurrentBag(); + + var processSpec = new ProcessSpec(toolExecutableResource.Command) + { + Arguments = commandLineArgs, + WorkingDirectory = Path.GetDirectoryName(toolExecutableResource.WorkingDirectory), + OnOutputData = data => + { + if (!string.IsNullOrEmpty(data)) + { + var writeTask = outputChannel.Writer.WriteAsync((data, false), cancellationToken).AsTask(); + sendingTasks.Add(writeTask); + } + }, + OnErrorData = data => + { + if (!string.IsNullOrEmpty(data)) + { + var writeTask = outputChannel.Writer.WriteAsync((data, true), cancellationToken).AsTask(); + sendingTasks.Add(writeTask); + } + } + }; + + _logger.LogDebug("Starting tool execution: {Command} at {WorkingDir} with args {Args}", processSpec.ExecutablePath, processSpec.WorkingDirectory, processSpec.Arguments); + var (processResultTask, disposable) = ProcessUtil.Run(processSpec); + var processWatcherTask = Task.Run(async () => + { + try + { + await processResultTask.ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + outputChannel.Writer.TryWrite(("failure:" + ex.Message, true)); + _logger.LogError(ex, "Process {executable} ended with exception", processSpec.ExecutablePath); + } + finally + { + await Task.WhenAll(sendingTasks).ConfigureAwait(false); + outputChannel.Writer.Complete(); + } + }, cancellationToken); + + await foreach (var (data, isError) in outputChannel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + yield return new CommandOutput + { + Text = data, + IsError = isError + }; + } + + await processWatcherTask.ConfigureAwait(false); + _logger.LogDebug("Finished tool execution: {Command} at {WorkingDir} with args {Args}", processSpec.ExecutablePath, processSpec.WorkingDirectory, processSpec.Arguments); + await disposable.DisposeAsync().ConfigureAwait(false); + } + + private async Task BuildCommandLineArgsAsync(IResource resource) + { + // create context to build command line arguments + var context = new CommandLineArgsCallbackContext(new List()); + + // from apphost explicit args + if (resource.TryGetAnnotationsOfType(out var args)) + { + foreach (var annotation in args) + { + if (annotation.Callback is null) + { + continue; + } + + await annotation.Callback(context).ConfigureAwait(false); + } + } + + // Attach args passed to `DistributedApplication` if present + if (_toolOptions.Args is { Length: > 0 }) + { + foreach (var arg in _toolOptions.Args) + { + context.Args.Add(arg); + } + } + + return string.Join(" ", context.Args.Select(x => x.ToString())); + } +} diff --git a/src/Aspire.Hosting/Tools/ToolOptions.cs b/src/Aspire.Hosting/Tools/ToolOptions.cs new file mode 100644 index 00000000000..dcafc1a5d38 --- /dev/null +++ b/src/Aspire.Hosting/Tools/ToolOptions.cs @@ -0,0 +1,30 @@ +// 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.Tools; + +/// +/// Represents the options for running the Aspire app host in tool mode. +/// +public class ToolOptions +{ + /// + /// The name of the tool configuration section in the appsettings.json file. + /// + public const string Section = "Tool"; + + /// + /// Gets or set the target resource name for the tool execution. + /// + public string? Resource { get; set; } + + /// + /// Target Aspire AppHost to run. + /// + public string? Project { get; set; } + + /// + /// Gets or set the custom args to pass to the tool executable. + /// + public string[]? Args { get; set; } +} diff --git a/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj b/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj index 24502b76997..e0d1a449e34 100644 --- a/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj +++ b/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj @@ -1,4 +1,4 @@ - + $(DefaultTargetFramework) @@ -15,6 +15,8 @@ + + @@ -23,4 +25,8 @@ + + + + diff --git a/tests/Aspire.Cli.Tests/E2E/ToolTests.cs b/tests/Aspire.Cli.Tests/E2E/ToolTests.cs new file mode 100644 index 00000000000..0289d040002 --- /dev/null +++ b/tests/Aspire.Cli.Tests/E2E/ToolTests.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Testing; +using Aspire.Hosting.Tools; +using Aspire.Hosting.Utils; +using Aspire.TestUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Projects; +using Xunit; + +namespace Aspire.Cli.Tests.E2E; + +public class ToolTests(ITestOutputHelper output) +{ + [Fact] + [RequiresDocker] + public async Task Exec_InitializeMigrations_ShouldCreateMigrationsInWebApp() + { + var myWebAppProjectMetadata = new TestingAppHost1_MyWebApp(); + DeleteMigrations(myWebAppProjectMetadata); + + string[] args = [ + // separate type of command + "--operation", "tool", + // what AppHost to target + "--project", myWebAppProjectMetadata.ProjectPath, + // what resource to target + "--tool", "migration-add", + + // if there are other args - it will break because EF does not process extra non-mapped args correctly + // "--add-postgres" + ]; + Action configureBuilder = (appOptions, _) => + { + }; + + var builder = DistributedApplicationTestingBuilder.Create(args, configureBuilder, typeof(TestingAppHost1_AppHost).Assembly) + .WithTestAndResourceLogging(output); + + // dependant of the target resource + var postgres = builder + .AddPostgres("postgres") + .WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000)); + var postresDb = postgres.AddDatabase("postgresDb"); + + // the target resource + var project = builder + .AddProject("mywebapp1") + .WithReference(postgres); + + builder + .AddExecutable( + name: "migration-add", + command: "dotnet", + workingDirectory: (new TestingAppHost1_MyWebApp()).ProjectPath, + args: "ef migrations add Init") + // note: there is an issue with dotnet-ef when artifacts are not in the local obj, so you have to specify the obj\ location + // https://github.com/dotnet/efcore/issues/23853#issuecomment-2183607932; + .WithArgs([ "--msbuildprojectextensionspath", "../../../artifacts/obj/TestingAppHost1.MyWebApp" ]) + .WithExplicitStart(); + + await using var app = await builder.BuildAsync(); + await app.StartAsync(); + + // in real world this would be invoked via cli, but we can resolve service for simplicity + var toolExecutionService = app.Services.GetRequiredService(); + var commandOutput = toolExecutionService.ExecuteToolAndStreamOutputAsync(CancellationToken.None); + await foreach (var command in commandOutput) + { + output.WriteLine($"Tool execution output: [iserror={command.IsError}] {command.Text}"); + } + + AssertMigrationsCreated(myWebAppProjectMetadata); + DeleteMigrations(myWebAppProjectMetadata); + } + + private static void DeleteMigrations(IProjectMetadata projectMetadata) + { + var projectDirectory = Path.GetDirectoryName(projectMetadata.ProjectPath); + if (!Directory.Exists(projectDirectory)) + { + return; + } + + var migrationDirectory = Path.Combine(projectDirectory!, "Migrations"); + if (!Directory.Exists(migrationDirectory)) + { + return; + } + + var migrationFiles = Directory.GetFiles(migrationDirectory); + foreach (var migrationFile in migrationFiles) + { + try + { + File.Delete(migrationFile); + } + catch (FileNotFoundException) + { + // ignore if not exists + } + } + } + + private static void AssertMigrationsCreated(IProjectMetadata projectMetadata) + { + var projectDirectory = Path.GetDirectoryName(projectMetadata.ProjectPath); + var migrationFiles = Directory.GetFiles(Path.Combine(projectDirectory!, "Migrations")); + Assert.NotEmpty(migrationFiles); + Assert.All(migrationFiles, file => + Assert.True(file.Contains("Init", StringComparison.OrdinalIgnoreCase) + || file.Contains("Snapshot", StringComparison.OrdinalIgnoreCase)) + ); + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostBackchannel.cs index e14b756e959..ed10ee3a113 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostBackchannel.cs @@ -141,4 +141,11 @@ public async Task GetCapabilitiesAsync(CancellationToken cancellationT return ["baseline.v2"]; } } + + public async IAsyncEnumerable GetToolExecutionOutputStreamAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return new CommandOutput() { Text = "test" }; + await Task.Delay(1, cancellationToken).ConfigureAwait(false); + yield break; + } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs index f971e497417..5b8b8bff9d7 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs @@ -78,4 +78,10 @@ public void DisplaySubtleMessage(string message) public void DisplayEmptyLine() { } -} \ No newline at end of file + + public void WriteConsoleLog(string message, bool isError = false) + { + var type = isError ? "Error" : "Info"; + Console.WriteLine($"[{type}] {message}"); + } +} diff --git a/tests/Shared/DistributedApplicationTestingBuilderExtensions.cs b/tests/Shared/DistributedApplicationTestingBuilderExtensions.cs index 77c2ac85b12..f5f6a233725 100644 --- a/tests/Shared/DistributedApplicationTestingBuilderExtensions.cs +++ b/tests/Shared/DistributedApplicationTestingBuilderExtensions.cs @@ -1,8 +1,10 @@ // 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.Backchannel; using Aspire.Hosting.Testing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Xunit; @@ -21,6 +23,7 @@ public static IDistributedApplicationTestingBuilder WithTestAndResourceLogging(t { builder.Services.AddXunitLogging(testOutputHelper); builder.Services.AddLogging(builder => builder.AddFilter("Aspire.Hosting", LogLevel.Trace)); + return builder; } diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs index 152644fbceb..c6cc2d96b64 100644 --- a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs @@ -7,6 +7,12 @@ var builder = DistributedApplication.CreateBuilder(args); +if (args.Contains("--add-postgres")) +{ + var postgres = builder.AddPostgres("postgres1"); + postgres.AddDatabase("postgresDb"); +} + builder.Configuration["ConnectionStrings:cs"] = "testconnection"; builder.AddConnectionString("cs"); @@ -22,6 +28,13 @@ .WithEnvironment("APP_HOST_ARG", builder.Configuration["APP_HOST_ARG"]) .WithEnvironment("LAUNCH_PROFILE_VAR_FROM_APP_HOST", builder.Configuration["LAUNCH_PROFILE_VAR_FROM_APP_HOST"]); +if (args.Contains("--add-migration-tool")) +{ + builder + .AddExecutable("migration-add", "dotnet", new Projects.TestingAppHost1_MyWebApp().ProjectPath, "ef migrations add Init") + .WithExplicitStart(); +} + if (builder.Configuration.GetValue("USE_HTTPS", false)) { webApp.WithExternalHttpEndpoints(); diff --git a/tests/TestingAppHost1/TestingAppHost1.MyWebApp/MyAppDbContext.cs b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/MyAppDbContext.cs new file mode 100644 index 00000000000..2f573bbf9e3 --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/MyAppDbContext.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore; + +namespace TestingAppHost1.MyWebApp; + +public class MyAppDbContext : DbContext +{ + public MyAppDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet MyEntities { get; set; } +} + +public class MyEntity +{ + public int Id { get; set; } + public string? Name { get; set; } +} diff --git a/tests/TestingAppHost1/TestingAppHost1.MyWebApp/Program.cs b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/Program.cs index c8b8b7a4831..36637232334 100644 --- a/tests/TestingAppHost1/TestingAppHost1.MyWebApp/Program.cs +++ b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/Program.cs @@ -1,10 +1,19 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.EntityFrameworkCore; +using TestingAppHost1.MyWebApp; + var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); +builder.Services.AddDbContextPool(options => +{ + var connectionString = builder.Configuration.GetConnectionString("mainDb"); + options.UseNpgsql(connectionString); +}); + // Add services to the container. var app = builder.Build(); diff --git a/tests/TestingAppHost1/TestingAppHost1.MyWebApp/TestingAppHost1.MyWebApp.csproj b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/TestingAppHost1.MyWebApp.csproj index c6392c97f23..1e0eec459ef 100644 --- a/tests/TestingAppHost1/TestingAppHost1.MyWebApp/TestingAppHost1.MyWebApp.csproj +++ b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/TestingAppHost1.MyWebApp.csproj @@ -10,4 +10,13 @@ + + + + + + + + +