|
// 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.Diagnostics;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Spectre.Console;
namespace Aspire.Cli.Commands;
internal sealed class PublishCommand : BaseCommand
{
private readonly ActivitySource _activitySource = new ActivitySource(nameof(PublishCommand));
private readonly IDotNetCliRunner _runner;
private readonly IInteractionService _interactionService;
private readonly IProjectLocator _projectLocator;
public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator)
: base("publish", "Generates deployment artifacts for an Aspire app host project.")
{
ArgumentNullException.ThrowIfNull(runner);
ArgumentNullException.ThrowIfNull(interactionService);
ArgumentNullException.ThrowIfNull(projectLocator);
_runner = runner;
_interactionService = interactionService;
_projectLocator = projectLocator;
var projectOption = new Option<FileInfo?>("--project");
projectOption.Description = "The path to the Aspire app host project file.";
projectOption.Validators.Add((result) => ProjectFileHelper.ValidateProjectOption(result, projectLocator));
Options.Add(projectOption);
var publisherOption = new Option<string>("--publisher", "-p");
publisherOption.Description = "The name of the publisher to use.";
Options.Add(publisherOption);
var outputPath = new Option<string>("--output-path", "-o");
outputPath.Description = "The output path for the generated artifacts.";
outputPath.DefaultValueFactory = (result) => Path.Combine(Environment.CurrentDirectory);
Options.Add(outputPath);
}
protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
(bool IsCompatibleAppHost, bool SupportsBackchannel, string? AspireHostingSdkVersion)? appHostCompatibilityCheck = null;
try
{
using var activity = _activitySource.StartActivity();
var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
var effectiveAppHostProjectFile = _projectLocator.UseOrFindAppHostProjectFile(passedAppHostProjectFile);
if (effectiveAppHostProjectFile is null)
{
return ExitCodeConstants.FailedToFindProject;
}
var env = new Dictionary<string, string>();
if (parseResult.GetValue<bool?>("--wait-for-debugger") ?? false)
{
env[KnownConfigNames.WaitForDebugger] = "true";
}
appHostCompatibilityCheck = await AppHostHelper.CheckAppHostCompatibilityAsync(_runner, _interactionService, effectiveAppHostProjectFile, cancellationToken);
if (!appHostCompatibilityCheck?.IsCompatibleAppHost ?? throw new InvalidOperationException("IsCompatibleAppHost is null"))
{
return ExitCodeConstants.FailedToDotnetRunAppHost;
}
var buildExitCode = await AppHostHelper.BuildAppHostAsync(_runner, _interactionService, effectiveAppHostProjectFile, cancellationToken);
if (buildExitCode != 0)
{
_interactionService.DisplayError("The project could not be built. For more information run with --debug switch.");
return ExitCodeConstants.FailedToBuildArtifacts;
}
var publisher = parseResult.GetValue<string>("--publisher");
var outputPath = parseResult.GetValue<string>("--output-path");
var fullyQualifiedOutputPath = Path.GetFullPath(outputPath ?? ".");
var publishersResult = await _interactionService.ShowStatusAsync<(int ExitCode, string[] Publishers)>(
publisher is { } ? ":package: Getting publisher..." : ":package: Getting publishers...",
async () => {
using var getPublishersActivity = _activitySource.StartActivity(
$"{nameof(ExecuteAsync)}-Action-GetPublishers",
ActivityKind.Client);
var backchannelCompletionSource = new TaskCompletionSource<AppHostBackchannel>();
var pendingInspectRun = _runner.RunAsync(
effectiveAppHostProjectFile,
false,
true,
["--operation", "inspect"],
null,
backchannelCompletionSource,
cancellationToken).ConfigureAwait(false);
var backchannel = await backchannelCompletionSource.Task.ConfigureAwait(false);
var publishers = await backchannel.GetPublishersAsync(cancellationToken).ConfigureAwait(false);
await backchannel.RequestStopAsync(cancellationToken).ConfigureAwait(false);
var exitCode = await pendingInspectRun;
return (exitCode, publishers);
}
);
if (publishersResult.ExitCode != 0)
{
_interactionService.DisplayError($"The publisher inspection failed with exit code {publishersResult.ExitCode}. For more information run with --debug switch.");
return ExitCodeConstants.FailedToBuildArtifacts;
}
var publishers = publishersResult.Publishers;
if (publishers is null || publishers.Length == 0)
{
_interactionService.DisplayError($"No publishers were found.");
return ExitCodeConstants.FailedToBuildArtifacts;
}
if (publishers?.Contains(publisher) != true)
{
if (publisher is not null)
{
_interactionService.DisplayMessage("warning", $"[yellow bold]The specified publisher '{publisher}' was not found.[/]");
}
publisher = await _interactionService.PromptForSelectionAsync(
"Select a publisher:",
publishers!,
(p) => p,
cancellationToken
);
}
_interactionService.DisplayMessage($"hammer_and_wrench", $"Generating artifacts for '{publisher}' publisher...");
var exitCode = await AnsiConsole.Progress()
.AutoRefresh(true)
.Columns(
new TaskDescriptionColumn() { Alignment = Justify.Left },
new ProgressBarColumn() { Width = 10 },
new ElapsedTimeColumn())
.StartAsync(async context => {
using var generateArtifactsActivity = _activitySource.StartActivity(
$"{nameof(ExecuteAsync)}-Action-GenerateArtifacts",
ActivityKind.Internal);
var backchannelCompletionSource = new TaskCompletionSource<AppHostBackchannel>();
var launchingAppHostTask = context.AddTask(":play_button: Launching apphost");
launchingAppHostTask.IsIndeterminate();
launchingAppHostTask.StartTask();
var pendingRun = _runner.RunAsync(
effectiveAppHostProjectFile,
false,
true,
["--publisher", publisher ?? "manifest", "--output-path", fullyQualifiedOutputPath],
env,
backchannelCompletionSource,
cancellationToken);
var backchannel = await backchannelCompletionSource.Task.ConfigureAwait(false);
launchingAppHostTask.Description = $":check_mark: Launching apphost";
launchingAppHostTask.Value = 100;
launchingAppHostTask.StopTask();
var publishingActivities = backchannel.GetPublishingActivitiesAsync(cancellationToken);
var progressTasks = new Dictionary<string, ProgressTask>();
await foreach (var publishingActivity in publishingActivities)
{
if (!progressTasks.TryGetValue(publishingActivity.Id, out var progressTask))
{
progressTask = context.AddTask(publishingActivity.Id);
progressTask.StartTask();
progressTask.IsIndeterminate();
progressTasks.Add(publishingActivity.Id, progressTask);
}
progressTask.Description = $":play_button: {publishingActivity.StatusText}";
if (publishingActivity.IsComplete && !publishingActivity.IsError)
{
progressTask.Description = $":check_mark: {publishingActivity.StatusText}";
progressTask.Value = 100;
progressTask.StopTask();
}
else if (publishingActivity.IsError)
{
progressTask.Description = $"[red bold]:cross_mark: {publishingActivity.StatusText}[/]";
progressTask.Value = 0;
break;
}
else
{
// Keep going man!
}
}
// When we are running in publish mode we don't want the app host to
// stop itself while we might still be streaming data back across
// the RPC backchannel. So we need to take responsibility for stopping
// the app host. If the CLI exits/crashes without explicitly stopping
// the app host the orphan detector in the app host will kick in.
if (progressTasks.Any(kvp => !kvp.Value.IsFinished))
{
// Depending on the failure the publisher may return a zero
// exit code.
await backchannel.RequestStopAsync(cancellationToken).ConfigureAwait(false);
var exitCode = await pendingRun;
// If we are in the state where we've detected an error because there
// is an incomplete task then we stop the app host, but depending on
// where/how the failure occured, we might still get a zero exit
// code. If we get a non-zero exit code we want to return that
// as it might be useful for diagnostic purposes, however if we don't
// get a non-zero exit code we want to return our built-in exit code
// for failed artifact build.
return exitCode == 0 ? ExitCodeConstants.FailedToBuildArtifacts : exitCode;
}
else
{
// If we are here then all the tasks are finished and we can
// stop the app host.
await backchannel.RequestStopAsync(cancellationToken).ConfigureAwait(false);
var exitCode = await pendingRun;
return exitCode; // should be zero for orderly shutdown but we pass it along anyway.
}
});
if (exitCode != 0)
{
_interactionService.DisplayError($"Publishing artifacts failed with exit code {exitCode}. For more information run with --debug switch.");
return ExitCodeConstants.FailedToBuildArtifacts;
}
else
{
_interactionService.DisplaySuccess($"Successfully published artifacts to: {fullyQualifiedOutputPath}");
return ExitCodeConstants.Success;
}
}
catch (ProjectLocatorException ex) when (ex.Message == "Project file does not exist.")
{
_interactionService.DisplayError("The --project option specified a project that does not exist.");
return ExitCodeConstants.FailedToFindProject;
}
catch (ProjectLocatorException ex) when (ex.Message.Contains("Nultiple project files"))
{
_interactionService.DisplayError("The --project option was not specified and multiple *.csproj files were detected.");
return ExitCodeConstants.FailedToFindProject;
}
catch (ProjectLocatorException ex) when (ex.Message.Contains("No project file"))
{
_interactionService.DisplayError("The project argument was not specified and no *.csproj files were detected.");
return ExitCodeConstants.FailedToFindProject;
}
catch (AppHostIncompatibleException ex)
{
return _interactionService.DisplayIncompatibleVersionError(
ex,
appHostCompatibilityCheck?.AspireHostingSdkVersion ?? throw new InvalidOperationException("AspireHostingSdkVersion is null")
);
}
}
}
|