|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.CommandLine;
using System.Globalization;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Certificates;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Spectre.Console;
namespace Aspire.Cli.Commands;
internal class ExecCommand : BaseCommand
{
private readonly IDotNetCliRunner _runner;
private readonly IInteractionService _interactionService;
private readonly ICertificateService _certificateService;
private readonly IProjectLocator _projectLocator;
private readonly IAnsiConsole _ansiConsole;
private readonly AspireCliTelemetry _telemetry;
public ExecCommand(
IDotNetCliRunner runner,
IInteractionService interactionService,
ICertificateService certificateService,
IProjectLocator projectLocator,
IAnsiConsole ansiConsole,
AspireCliTelemetry telemetry,
IFeatures features,
ICliUpdateNotifier updateNotifier)
: base("exec", ExecCommandStrings.Description, features, updateNotifier)
{
ArgumentNullException.ThrowIfNull(runner);
ArgumentNullException.ThrowIfNull(interactionService);
ArgumentNullException.ThrowIfNull(certificateService);
ArgumentNullException.ThrowIfNull(projectLocator);
ArgumentNullException.ThrowIfNull(ansiConsole);
ArgumentNullException.ThrowIfNull(telemetry);
_runner = runner;
_interactionService = interactionService;
_certificateService = certificateService;
_projectLocator = projectLocator;
_ansiConsole = ansiConsole;
_telemetry = telemetry;
var projectOption = new Option<FileInfo?>("--project");
projectOption.Description = ExecCommandStrings.ProjectArgumentDescription;
Options.Add(projectOption);
var resourceOption = new Option<string>("--resource", "-r");
resourceOption.Description = ExecCommandStrings.TargetResourceArgumentDescription;
Options.Add(resourceOption);
var startResourceOption = new Option<string>("--start-resource", "-s");
startResourceOption.Description = ExecCommandStrings.StartTargetResourceArgumentDescription;
Options.Add(startResourceOption);
TreatUnmatchedTokensAsErrors = false;
}
protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var buildOutputCollector = new OutputCollector();
var runOutputCollector = new OutputCollector();
IAppHostBackchannel? backchannel = null;
Task<int>? pendingRun = null;
int? commandExitCode = null;
(bool IsCompatibleAppHost, bool SupportsBackchannel, string? AspireHostingVersion)? appHostCompatibilityCheck = null;
try
{
using var activity = _telemetry.ActivitySource.StartActivity(this.Name);
var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
var effectiveAppHostProjectFile = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, cancellationToken);
if (effectiveAppHostProjectFile is null)
{
return ExitCodeConstants.FailedToFindProject;
}
var env = new Dictionary<string, string>();
var waitForDebugger = parseResult.GetValue<bool>("--wait-for-debugger");
if (waitForDebugger)
{
env[KnownConfigNames.WaitForDebugger] = "true";
}
appHostCompatibilityCheck = await AppHostHelper.CheckAppHostCompatibilityAsync(_runner, _interactionService, effectiveAppHostProjectFile, _telemetry, cancellationToken);
if (!appHostCompatibilityCheck?.IsCompatibleAppHost ?? throw new InvalidOperationException(RunCommandStrings.IsCompatibleAppHostIsNull))
{
return ExitCodeConstants.FailedToDotnetRunAppHost;
}
var runOptions = new DotNetCliRunnerInvocationOptions
{
StandardOutputCallback = runOutputCollector.AppendOutput,
StandardErrorCallback = runOutputCollector.AppendError,
};
var targetResourceMode = "--resource";
var targetResource = parseResult.GetValue<string>("--resource");
if (string.IsNullOrEmpty(targetResource))
{
targetResourceMode = "--start-resource";
targetResource = parseResult.GetValue<string>("--start-resource");
}
if (targetResource is null)
{
_interactionService.DisplayError(ExecCommandStrings.TargetResourceNotSpecified);
return ExitCodeConstants.InvalidCommand;
}
var (arbitraryFlags, commandTokens) = ParseCmdArgs(parseResult);
if (commandTokens is null || commandTokens.Count == 0)
{
_interactionService.DisplayError(ExecCommandStrings.FailedToParseCommand);
return ExitCodeConstants.InvalidCommand;
}
string[] args = [
"--operation", "run",
targetResourceMode, targetResource!,
// a bit hacky, but in order to pass a full command with possible quotes and etc properly without losing the signature
// we can wrap it in a string and pass it as a single argument
"--command", $"\"{string.Join(" ", commandTokens)}\"",
..arbitraryFlags,
];
try
{
var backchannelCompletionSource = new TaskCompletionSource<IAppHostBackchannel>();
pendingRun = _runner.RunAsync(
projectFile: effectiveAppHostProjectFile,
watch: false,
noBuild: false,
args: args,
env: env,
backchannelCompletionSource: backchannelCompletionSource,
options: runOptions,
cancellationToken: cancellationToken);
// We wait for the back channel to be created to signal that
// the AppHost is ready to accept requests.
backchannel = await _interactionService.ShowStatusAsync(
$":linked_paperclips: {RunCommandStrings.StartingAppHost}",
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(emoji: "bug", InteractionServiceStrings.WaitingForDebuggerToAttachToAppHost);
}
// 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 backchannelCompletionSource.Task.WaitAsync(cancellationToken);
return backchannel;
});
commandExitCode = await _interactionService.ShowStatusAsync<int?>(
":running_shoe: Running exec...",
async () =>
{
// execute tool and stream the output
int? exitCode = null;
var outputStream = backchannel.ExecAsync(cancellationToken);
await foreach (var output in outputStream)
{
_interactionService.WriteConsoleLog(output.Text, output.LineNumber, output.Type, output.IsErrorMessage);
if (output.ExitCode is not null)
{
exitCode = output.ExitCode;
}
}
return exitCode;
});
}
finally
{
if (backchannel is not null)
{
_ = await _interactionService.ShowStatusAsync<int>(
":linked_paperclips: Stopping app host...",
async () =>
{
await backchannel.RequestStopAsync(cancellationToken);
return ExitCodeConstants.Success;
});
}
}
if (commandExitCode is not null)
{
// if there is a deterministic output of the command with exit code - we should display that
return commandExitCode.Value;
}
if (pendingRun is not null)
{
var result = await pendingRun;
if (result != 0)
{
_interactionService.DisplayLines(runOutputCollector.GetLines());
_interactionService.DisplayError(RunCommandStrings.ProjectCouldNotBeRun);
return result;
}
else
{
return ExitCodeConstants.Success;
}
}
else
{
_interactionService.DisplayLines(runOutputCollector.GetLines());
_interactionService.DisplayError(RunCommandStrings.ProjectCouldNotBeRun);
return ExitCodeConstants.FailedToDotnetRunAppHost;
}
}
catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken)
{
_interactionService.DisplayCancellationMessage();
return ExitCodeConstants.Success;
}
catch (ProjectLocatorException ex) when (string.Equals(ex.Message, ErrorStrings.ProjectFileDoesntExist, StringComparisons.CliInputOrOutput))
{
_interactionService.DisplayError(InteractionServiceStrings.ProjectOptionDoesntExist);
return ExitCodeConstants.FailedToFindProject;
}
catch (ProjectLocatorException ex) when (string.Equals(ex.Message, ErrorStrings.MultipleProjectFilesFound, StringComparisons.CliInputOrOutput))
{
_interactionService.DisplayError(InteractionServiceStrings.ProjectOptionNotSpecifiedMultipleAppHostsFound);
return ExitCodeConstants.FailedToFindProject;
}
catch (ProjectLocatorException ex) when (string.Equals(ex.Message, ErrorStrings.NoProjectFileFound, StringComparisons.CliInputOrOutput))
{
_interactionService.DisplayError(InteractionServiceStrings.ProjectOptionNotSpecifiedNoCsprojFound);
return ExitCodeConstants.FailedToFindProject;
}
catch (AppHostIncompatibleException ex)
{
return _interactionService.DisplayIncompatibleVersionError(
ex,
appHostCompatibilityCheck?.AspireHostingVersion ?? throw new InvalidOperationException(ErrorStrings.AspireHostingVersionNull)
);
}
catch (CertificateServiceException ex)
{
_interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.CertificateTrustError, ex.Message));
return ExitCodeConstants.FailedToTrustCertificates;
}
catch (FailedToConnectBackchannelConnection ex)
{
_interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ErrorConnectingToAppHost, ex.Message));
_interactionService.DisplayLines(runOutputCollector.GetLines());
return ExitCodeConstants.FailedToDotnetRunAppHost;
}
catch (Exception ex)
{
_interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message));
_interactionService.DisplayLines(runOutputCollector.GetLines());
return ExitCodeConstants.FailedToDotnetRunAppHost;
}
}
private (ICollection<string> arbitary, ICollection<string> command) ParseCmdArgs(ParseResult parseResult)
{
var allTokens = parseResult.UnmatchedTokens.ToList();
int delimiterIndex = allTokens.IndexOf("--");
List<string> arbitraryFlags = new();
List<string> commandTokens = new();
// Find the index of the first token that is not an option (doesn't start with '-') and is not the value for a known option
// We'll use the options defined in this command to skip known option values
var knownOptions = new HashSet<string>(Options.SelectMany(o => o.Aliases));
int i = 0;
while (i < allTokens.Count)
{
if (delimiterIndex >= 0 && i == delimiterIndex)
{
// Everything after -- is command
commandTokens.AddRange(allTokens.Skip(i + 1));
break;
}
var token = allTokens[i];
if (knownOptions.Contains(token))
{
// Skip the option and its value (if it has one)
var option = Options.FirstOrDefault(o => o.Aliases.Contains(token));
if (option is not null)
{
// If the option is not a bool, it expects a value
var isFlag = option.Arity.MaximumNumberOfValues == 0;
if (!isFlag && i + 1 < allTokens.Count)
{
i += 2;
continue;
}
}
i++;
continue;
}
else if (token.StartsWith("-"))
{
// Arbitrary flag
arbitraryFlags.Add(token);
i++;
continue;
}
else
{
// First non-option, non-flag token is the start of the command (if not using --)
commandTokens.AddRange(allTokens.Skip(i));
break;
}
}
return (arbitraryFlags, commandTokens);
}
}
|