File: Commands\PublishCommandBase.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// 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.CommandLine;
using System.CommandLine.Parsing;
using System.Diagnostics;
using System.Globalization;
using Aspire.Cli.Backchannel;
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;
using Spectre.Console.Rendering;
 
namespace Aspire.Cli.Commands;
 
internal abstract class PublishCommandBase : BaseCommand
{
    protected readonly IDotNetCliRunner _runner;
    protected readonly IInteractionService _interactionService;
    protected readonly IProjectLocator _projectLocator;
    protected readonly AspireCliTelemetry _telemetry;
 
    protected PublishCommandBase(string name, string description, IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry)
        : base(name, description)
    {
        ArgumentNullException.ThrowIfNull(runner);
        ArgumentNullException.ThrowIfNull(interactionService);
        ArgumentNullException.ThrowIfNull(projectLocator);
        ArgumentNullException.ThrowIfNull(telemetry);
 
        _runner = runner;
        _interactionService = interactionService;
        _projectLocator = projectLocator;
        _telemetry = telemetry;
 
        var projectOption = new Option<FileInfo?>("--project")
        {
            Description = PublishCommandStrings.ProjectArgumentDescription
        };
        Options.Add(projectOption);
 
        var outputPath = new Option<string>("--output-path", "-o")
        {
            Description = GetOutputPathDescription(),
            DefaultValueFactory = GetDefaultOutputPath
        };
        Options.Add(outputPath);
 
        // In the publish and deploy commands we forward all unrecognized tokens
        // through to the underlying tooling when we launch the app host.
        TreatUnmatchedTokensAsErrors = false;
    }
 
    protected abstract string GetOutputPathDescription();
    protected abstract string GetDefaultOutputPath(ArgumentResult result);
    protected abstract string[] GetRunArguments(string fullyQualifiedOutputPath, string[] unmatchedTokens);
    protected abstract string GetSuccessMessage(string fullyQualifiedOutputPath);
    protected abstract string GetFailureMessage(int exitCode);
    protected abstract string GetCanceledMessage();
    protected abstract string GetProgressMessage();
 
    protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        var buildOutputCollector = new OutputCollector();
        var operationOutputCollector = new OutputCollector();
 
        (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") ?? false;
            if (waitForDebugger)
            {
                env[KnownConfigNames.WaitForDebugger] = "true";
            }
 
            appHostCompatibilityCheck = await AppHostHelper.CheckAppHostCompatibilityAsync(_runner, _interactionService, effectiveAppHostProjectFile, _telemetry, cancellationToken);
 
            if (!appHostCompatibilityCheck?.IsCompatibleAppHost ?? throw new InvalidOperationException("IsCompatibleAppHost is null"))
            {
                return ExitCodeConstants.AppHostIncompatible;
            }
 
            var buildOptions = new DotNetCliRunnerInvocationOptions
            {
                StandardOutputCallback = buildOutputCollector.AppendOutput,
                StandardErrorCallback = buildOutputCollector.AppendError,
            };
 
            var buildExitCode = await AppHostHelper.BuildAppHostAsync(_runner, _interactionService, effectiveAppHostProjectFile, buildOptions, cancellationToken);
 
            if (buildExitCode != 0)
            {
                _interactionService.DisplayLines(buildOutputCollector.GetLines());
                _interactionService.DisplayError(InteractionServiceStrings.ProjectCouldNotBeBuilt);
                return ExitCodeConstants.FailedToBuildArtifacts;
            }
 
            var outputPath = parseResult.GetValue<string>("--output-path");
            var fullyQualifiedOutputPath = Path.GetFullPath(outputPath ?? ".");
 
            _interactionService.DisplayMessage($"hammer_and_wrench", GetProgressMessage());
 
            var backchannelCompletionSource = new TaskCompletionSource<IAppHostBackchannel>();
 
            var operationRunOptions = new DotNetCliRunnerInvocationOptions
            {
                StandardOutputCallback = operationOutputCollector.AppendOutput,
                StandardErrorCallback = operationOutputCollector.AppendError,
                NoLaunchProfile = true
            };
 
            var unmatchedTokens = parseResult.UnmatchedTokens.ToArray();
 
            var pendingRun = _runner.RunAsync(
                effectiveAppHostProjectFile,
                false,
                true,
                GetRunArguments(fullyQualifiedOutputPath, unmatchedTokens),
                env,
                backchannelCompletionSource,
                operationRunOptions,
                cancellationToken);
 
            // 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", InteractionServiceStrings.WaitingForDebuggerToAttachToAppHost);
            }
 
            var backchannel = await backchannelCompletionSource.Task.ConfigureAwait(false);
            var publishingActivities = backchannel.GetPublishingActivitiesAsync(cancellationToken);
 
            var debugMode = parseResult.GetValue<bool?>("--debug") ?? false;
 
            var noFailuresReported = debugMode switch
            {
                true => await ProcessPublishingActivitiesAsync(publishingActivities, cancellationToken),
                false => await ProcessAndDisplayPublishingActivitiesAsync(publishingActivities, cancellationToken),
            };
 
            await backchannel.RequestStopAsync(cancellationToken).ConfigureAwait(false);
            var exitCode = await pendingRun;
 
            if (exitCode == 0 && noFailuresReported)
            {
                _interactionService.DisplaySuccess(GetSuccessMessage(fullyQualifiedOutputPath));
                return ExitCodeConstants.Success;
            }
 
            _interactionService.DisplayLines(operationOutputCollector.GetLines());
            _interactionService.DisplayError(GetFailureMessage(exitCode));
            return ExitCodeConstants.FailedToBuildArtifacts;
        }
        catch (OperationCanceledException)
        {
            _interactionService.DisplayError(GetCanceledMessage());
            return ExitCodeConstants.FailedToBuildArtifacts;
        }
        catch (ProjectLocatorException ex) when (string.Equals(ex.Message, ErrorStrings.ProjectFileNotAppHostProject, StringComparisons.CliInputOrOutput))
        {
            _interactionService.DisplayError(InteractionServiceStrings.SpecifiedProjectFileNotAppHostProject);
            return ExitCodeConstants.FailedToFindProject;
        }
        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 (FailedToConnectBackchannelConnection ex)
        {
            _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ErrorConnectingToAppHost, ex.Message));
            _interactionService.DisplayLines(operationOutputCollector.GetLines());
            return ExitCodeConstants.FailedToBuildArtifacts;
        }
        catch (Exception ex)
        {
            _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message));
            return ExitCodeConstants.FailedToBuildArtifacts;
        }
    }
 
    public static async Task<bool> ProcessPublishingActivitiesAsync(IAsyncEnumerable<PublishingActivity> publishingActivities, CancellationToken cancellationToken)
    {
        await foreach (var publishingActivity in publishingActivities.WithCancellation(cancellationToken))
        {
            if (publishingActivity.Type == PublishingActivityTypes.PublishComplete)
            {
                return !publishingActivity.Data.IsError;
            }
        }
 
        return true;
    }
 
    public static async Task<bool> ProcessAndDisplayPublishingActivitiesAsync(IAsyncEnumerable<PublishingActivity> publishingActivities, CancellationToken cancellationToken)
    {
        var stepCounter = 1;
        var steps = new Dictionary<string, StepInfo>();
        PublishingActivity? publishingActivity = null;
        var currentStepProgress = new ProgressContextInfo();
 
        await foreach (var activity in publishingActivities.WithCancellation(cancellationToken))
        {
            // PublishComplete is emitted at the end of the publishing process
            // by the DistributedApplicationRunner. Display the final status and
            // cancel any in-progress tasks when this happens.
            if (activity.Type == PublishingActivityTypes.PublishComplete)
            {
                publishingActivity = activity;
 
                break;
            }
            else if (activity.Type == PublishingActivityTypes.Step)
            {
                // If this is our first time encountering this step, initialize it by
                // display the step header and configuring a new ProgressContext for the
                // tasks that will be parented to this step.
                if (!steps.TryGetValue(activity.Data.Id, out var stepInfo))
                {
                    if (currentStepProgress.Step is not null)
                    {
                        throw new InvalidOperationException($"Step activity with ID '{currentStepProgress.Step?.Id}' is not complete. Expected it to be complete before processing tasks.");
                    }
 
                    stepInfo = new StepInfo
                    {
                        Id = activity.Data.Id,
                        Title = activity.Data.StatusText,
                        Number = stepCounter++,
                        StartTime = DateTime.UtcNow
                    };
 
                    steps[activity.Data.Id] = stepInfo;
 
                    AnsiConsole.WriteLine();
                    AnsiConsole.MarkupLine($"[bold]Step {stepInfo.Number}: {stepInfo.Title.EscapeMarkup()}[/]");
 
                    currentStepProgress = new ProgressContextInfo { Step = stepInfo };
                }
                // If the step is complete, update the step info, clear out any pending progress tasks, and
                // display the completion status associated with the the step.
                else if (activity.Data.IsComplete)
                {
                    stepInfo.IsComplete = true;
                    stepInfo.IsError = activity.Data.IsError;
                    stepInfo.CompletionText = activity.Data.StatusText;
 
                    await currentStepProgress.DisposeAsync();
 
                    if (stepInfo.IsError)
                    {
                        AnsiConsole.MarkupLine($"[red bold]❌ FAILED:[/] {stepInfo.CompletionText.EscapeMarkup()}");
                    }
                    else
                    {
                        AnsiConsole.MarkupLine($"[green bold]✅ COMPLETED:[/] {stepInfo.CompletionText.EscapeMarkup()}");
                    }
 
                    AnsiConsole.WriteLine();
                    AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey")).DoubleBorder().LeftJustified());
                    AnsiConsole.WriteLine();
 
                    // Clean up the current progress context and reset the step ID so that it
                    // can be reused by the next step.
                    currentStepProgress = new ProgressContextInfo();
                }
                else
                {
                    throw new InvalidOperationException($"Step activity with ID '{activity.Data.Id}' is not complete. Expected it to be complete before processing tasks.");
                }
            }
            else
            {
                var stepId = activity.Data.StepId;
                Debug.Assert(stepId != null, "Activity data should have a StepId for task activities.");
 
                if (currentStepProgress.Step?.Id != stepId)
                {
                    throw new InvalidOperationException($"Task activity with ID '{activity.Data.Id}' is not associated with the current step '{currentStepProgress.Step?.Id}'.");
                }
 
                var tasks = currentStepProgress.Step.Tasks;
 
                await StartProgressForStep(currentStepProgress, cancellationToken);
 
                if (!tasks.TryGetValue(activity.Data.Id, out var task))
                {
                    task = new TaskInfo
                    {
                        Id = activity.Data.Id,
                        StatusText = activity.Data.StatusText,
                        StartTime = DateTime.UtcNow
                    };
 
                    tasks[activity.Data.Id] = task;
 
                    // Start progress context on first task for this step
                    task.ProgressTask = currentStepProgress.Ctx!.AddTask($"  {activity.Data.StatusText.EscapeMarkup()}");
                    task.ProgressTask.IsIndeterminate = true;
                }
 
                if (task.ProgressTask is null)
                {
                    throw new InvalidOperationException($"Task with ID '{activity.Data.Id}' does not have an associated ProgressTask.");
                }
 
                task.StatusText = activity.Data.StatusText;
                task.IsComplete = activity.Data.IsComplete;
                task.IsError = activity.Data.IsError;
                task.IsWarning = activity.Data.IsWarning;
 
                if (task.IsError || task.IsWarning || task.IsComplete)
                {
                    var prefix = task.IsError ? "[red]✗ FAILED:[/]" :
                        task.IsWarning ? "[yellow]⚠ WARNING:[/]" : "[green]✓ DONE:[/]";
                    task.ProgressTask.Description = $"  {prefix} {task.StatusText.EscapeMarkup()}";
                    task.CompletionMessage = activity.Data.CompletionMessage;
 
                    // Add completion message to the shared dictionary so that it can be displayed after the status text in the column view.
                    if (currentStepProgress.TaskCompletionMessages != null && !string.IsNullOrEmpty(activity.Data.CompletionMessage))
                    {
                        currentStepProgress.TaskCompletionMessages[task.ProgressTask.Id] = activity.Data.CompletionMessage;
                    }
 
                    // We don't set hasErrors = true on task errors to avoid early exits. We only
                    // process errors captured at the step-level or publish complete level.
                    task.ProgressTask.StopTask();
                }
                else
                {
                    task.ProgressTask.Description = $"  {task.StatusText.EscapeMarkup()}";
                }
            }
        }
 
        var hasErrors = publishingActivity?.Data.IsError ?? false;
 
        if (publishingActivity is not null)
        {
            if (hasErrors)
            {
                AnsiConsole.MarkupLine($"[red bold]❌ PUBLISHING FAILED:[/] {publishingActivity.Data.StatusText.EscapeMarkup()}");
            }
            else
            {
                AnsiConsole.MarkupLine($"[green bold]✅ PUBLISHING COMPLETED:[/] {publishingActivity.Data.StatusText.EscapeMarkup()}");
            }
        }
 
        return !hasErrors;
    }
 
    private static async Task StartProgressForStep(ProgressContextInfo progressContext, CancellationToken cancellationToken)
    {
        if (progressContext.Context is not null)
        {
            // If the context is already started, we don't need to do anything.
            return;
        }
 
        progressContext.Context = AnsiConsole.Progress()
            .AutoClear(false)
            .HideCompleted(false)
            .Columns(
            [
                new SpinnerColumn(Spinner.Known.BouncingBar) { Style = Style.Parse("yellow") },
                new TaskDescriptionWithCompletionColumn(progressContext.TaskCompletionMessages),
                new ElapsedTimeColumn() { Style = Style.Parse("grey") }
            ]);
 
        // Use a TaskCompletionSource to signal when the context is ready
        var contextReadySource = new TaskCompletionSource<ProgressContext>(TaskCreationOptions.RunContinuationsAsynchronously);
 
        progressContext.ContextTask = progressContext.Context.StartAsync(async ctx =>
        {
            // Signal that the context is ready so that the invoker can start to populate
            // it with tasks
            progressContext.Ctx = ctx;
            contextReadySource.SetResult(ctx);
 
            // Cancel the Spectre progress context when a cancellation is requested
            // explicitly by the ProgressContext.CancellationTokenSource.
            await progressContext.KeepProgressContextAliveTcs.Task.WaitAsync(cancellationToken)
                .ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
        });
 
        // Wait for the context to be ready before returning
        await contextReadySource.Task;
    }
 
    private class StepInfo
    {
        public string Id { get; set; } = string.Empty;
        public string Title { get; set; } = string.Empty;
        public int Number { get; set; }
        public DateTime StartTime { get; set; }
        public bool IsComplete { get; set; }
        public bool IsError { get; set; }
        public string CompletionText { get; set; } = string.Empty;
        public Dictionary<string, TaskInfo> Tasks { get; } = [];
    }
 
    private class TaskInfo
    {
        public string Id { get; set; } = string.Empty;
        public string StatusText { get; set; } = string.Empty;
        public DateTime StartTime { get; set; }
        public bool IsComplete { get; set; }
        public bool IsError { get; set; }
        public bool IsWarning { get; set; }
        public string? CompletionMessage { get; set; }
        public ProgressTask? ProgressTask { get; set; }
    }
 
    private class ProgressContextInfo : IAsyncDisposable
    {
        public StepInfo? Step { get; set; }
        public Progress? Context { get; set; }
        public Task? ContextTask { get; set; }
        public ProgressContext? Ctx { get; set; }
        public TaskCompletionSource KeepProgressContextAliveTcs { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
        // Dictionary to track completion messages for tasks so that they can be rendered
        // below the task description in the progress view using the custom column implementation.
        public ConcurrentDictionary<int, string> TaskCompletionMessages { get; } = [];
 
        public async ValueTask DisposeAsync()
        {
            KeepProgressContextAliveTcs.TrySetResult();
 
            if (ContextTask is not null)
            {
                await ContextTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
            }
        }
    }
 
    // Custom column type to display the status text associated with task
    // and the optional completion message if the task has completed below
    // it.
    private class TaskDescriptionWithCompletionColumn(ConcurrentDictionary<int, string> completionMessages) : ProgressColumn
    {
        public override IRenderable Render(RenderOptions options, ProgressTask task, TimeSpan deltaTime)
        {
            var description = task.Description ?? string.Empty;
 
            if (completionMessages.TryGetValue(task.Id, out var completionMessage) && !string.IsNullOrEmpty(completionMessage))
            {
                List<IRenderable> items =
                [
                    new Markup(description),
                    new Markup($"    [dim]{completionMessage.EscapeMarkup()}[/]")
                ];
 
                return new Rows(items);
            }
 
            return new Markup(description);
        }
    }
}