Skip to content

feat: run apphost in tool mode #9912

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/Aspire.Cli/Backchannel/AppHostBackchannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> GetCapabilitiesAsync(CancellationToken cancellationToken);
IAsyncEnumerable<CommandOutput> GetToolExecutionOutputStreamAsync(CancellationToken cancellationToken);
}

internal sealed class AppHostBackchannel(ILogger<AppHostBackchannel> logger, CliRpcTarget target, AspireCliTelemetry telemetry) : IAppHostBackchannel
Expand Down Expand Up @@ -179,4 +180,23 @@ public async Task<string[]> GetCapabilitiesAsync(CancellationToken cancellationT

return capabilities;
}

public async IAsyncEnumerable<CommandOutput> 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<IAsyncEnumerable<CommandOutput>>(
"ExecuteToolAndStreamOutputAsync",
Array.Empty<object>(),
cancellationToken);

logger.LogDebug("Receiving tool output...");
await foreach (var output in outputMessages.WithCancellation(cancellationToken))
{
yield return output;
}
}
}
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Backchannel/CliRpcTarget.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ namespace Aspire.Cli.Backchannel;

internal class CliRpcTarget
{
}
}
10 changes: 10 additions & 0 deletions src/Aspire.Cli/Backchannel/CommandOutput.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
70 changes: 66 additions & 4 deletions src/Aspire.Cli/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ public RunCommand(IDotNetCliRunner runner, IInteractionService interactionServic
watchOption.Description = RunCommandStrings.WatchArgumentDescription;
Options.Add(watchOption);

var toolParseOption = new Option<string>("--tool", "-t");
toolParseOption.Description = "Runs a resource as a tool.";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to localize.

Options.Add(toolParseOption);

TreatUnmatchedTokensAsErrors = false;
}

Expand Down Expand Up @@ -124,19 +128,77 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell

var backchannelCompletitionSource = new TaskCompletionSource<IAppHostBackchannel>();

var unmatchedTokens = parseResult.UnmatchedTokens.ToArray();
string[] runArgs;
var tool = parseResult.GetValue<string>("--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<int>(
":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<int>(
":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.
Expand Down Expand Up @@ -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());
Expand Down
9 changes: 9 additions & 0 deletions src/Aspire.Cli/Interaction/ConsoleInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}[/]");
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Cli/Interaction/IInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
14 changes: 14 additions & 0 deletions src/Aspire.Cli/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,26 @@ public static IDistributedApplicationTestingBuilder Create(string[] args, Action
return new TestingBuilder(args, configureBuilder);
}

/// <summary>
/// Creates a new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
/// </summary>
/// <param name="args">The command line arguments to pass to the entry point.</param>
/// <param name="configureBuilder">The delegate used to configure the builder.</param>
/// <param name="appHostAssembly">The assembly of app host</param>
/// <returns>
/// A new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
/// </returns>
public static IDistributedApplicationTestingBuilder Create(
string[] args,
Action<DistributedApplicationOptions, HostApplicationBuilderSettings> configureBuilder,
Assembly appHostAssembly)
{
ThrowIfNullOrContainsIsNullOrEmpty(args);
ArgumentNullException.ThrowIfNull(configureBuilder);

return new TestingBuilder(args, configureBuilder, appHostAssembly);
}

private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] args)
{
ArgumentNullException.ThrowIfNull(args);
Expand Down Expand Up @@ -293,18 +313,30 @@ public async Task StopAsync(CancellationToken cancellationToken)

private sealed class TestingBuilder(
string[] args,
Action<DistributedApplicationOptions, HostApplicationBuilderSettings> configureBuilder) : IDistributedApplicationTestingBuilder
Action<DistributedApplicationOptions, HostApplicationBuilderSettings> 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<DistributedApplicationOptions, HostApplicationBuilderSettings> configureBuilder)
Action<DistributedApplicationOptions, HostApplicationBuilderSettings> 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))
Expand Down
12 changes: 9 additions & 3 deletions src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,9 +21,14 @@ internal class AppHostRpcTarget(
IServiceProvider serviceProvider,
PublishingActivityProgressReporter activityReporter,
IHostApplicationLifetime lifetime,
DistributedApplicationOptions options
)
DistributedApplicationOptions options,
ToolExecutionService toolExecutionService)
{
public IAsyncEnumerable<CommandOutput> 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)
Expand Down Expand Up @@ -178,4 +184,4 @@ public Task<string[]> GetCapabilitiesAsync(CancellationToken cancellationToken)
});
}
#pragma warning restore CA1822
}
}
8 changes: 7 additions & 1 deletion src/Aspire.Hosting/Backchannel/BackchannelService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@

namespace Aspire.Hosting.Cli;

internal sealed class BackchannelService(ILogger<BackchannelService> logger, IConfiguration configuration, AppHostRpcTarget appHostRpcTarget, IDistributedApplicationEventing eventing, IServiceProvider serviceProvider) : BackgroundService
internal sealed class BackchannelService(
ILogger<BackchannelService> logger,
IConfiguration configuration,
AppHostRpcTarget appHostRpcTarget,
IDistributedApplicationEventing eventing,
IServiceProvider serviceProvider)
: BackgroundService
{
private JsonRpc? _rpc;

Expand Down
10 changes: 10 additions & 0 deletions src/Aspire.Hosting/Backchannel/CommandOutput.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
Loading
Loading