File: Commands\WaitCommand.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.CommandLine;
using System.Globalization;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Resources;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Commands;
 
internal sealed class WaitCommand : BaseCommand
{
    internal override HelpGroup HelpGroup => HelpGroup.ResourceManagement;
 
    private readonly AppHostConnectionResolver _connectionResolver;
    private readonly ILogger<WaitCommand> _logger;
    private readonly ResourceWaitService _resourceWaitService;
    private readonly TimeProvider _timeProvider;
 
    private static readonly Argument<string> s_resourceArgument = new("resource")
    {
        Description = WaitCommandStrings.ResourceArgumentDescription
    };
 
    private static readonly Option<string> s_statusOption = new("--status")
    {
        Description = WaitCommandStrings.StatusOptionDescription,
        DefaultValueFactory = _ => "healthy"
    };
 
    internal const int DefaultTimeoutSeconds = 120;
 
    private static readonly Option<int> s_timeoutOption = new("--timeout")
    {
        Description = WaitCommandStrings.TimeoutOptionDescription,
        DefaultValueFactory = _ => DefaultTimeoutSeconds
    };
 
    private static readonly OptionWithLegacy<FileInfo?> s_appHostOption = new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription);
 
    public WaitCommand(
        AppHostConnectionResolver connectionResolver,
        ILogger<WaitCommand> logger,
        ResourceWaitService resourceWaitService,
        CommonCommandServices services,
        TimeProvider timeProvider)
        : base("wait", WaitCommandStrings.Description, services)
    {
        _connectionResolver = connectionResolver;
        _logger = logger;
        _resourceWaitService = resourceWaitService;
        _timeProvider = timeProvider;
 
        Arguments.Add(s_resourceArgument);
        Options.Add(s_statusOption);
        Options.Add(s_timeoutOption);
        Options.Add(s_appHostOption);
    }
 
    protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        using var activity = Telemetry.StartDiagnosticActivity(Name);
 
        var resourceName = parseResult.GetValue(s_resourceArgument)!;
        var status = parseResult.GetValue(s_statusOption)!.ToLowerInvariant();
        var timeoutSeconds = parseResult.GetValue(s_timeoutOption);
        var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption);
 
        // Validate status value
        if (!IsValidStatus(status))
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand, string.Format(CultureInfo.CurrentCulture, WaitCommandStrings.InvalidStatusValue, status));
        }
 
        // Validate timeout
        if (timeoutSeconds <= 0)
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand, WaitCommandStrings.TimeoutMustBePositive);
        }
 
        // Resolve connection to a running AppHost
        var result = await _connectionResolver.ResolveConnectionAsync(
            passedAppHostProjectFile,
            SharedCommandStrings.ScanningForRunningAppHosts,
            string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, WaitCommandStrings.SelectAppHostAction),
            SharedCommandStrings.AppHostNotRunning,
            cancellationToken);
 
        if (!result.Success)
        {
            return CommandResult.FromExitCode(AppHostConnectionResultHandler.DisplayFailureAsError(result, InteractionService, CliExitCodes.FailedToFindProject));
        }
 
        var connection = result.Connection!;
 
        return CommandResult.FromExitCode(await WaitForResourceAsync(connection, resourceName, status, timeoutSeconds, cancellationToken));
    }
 
    private async Task<int> WaitForResourceAsync(
        IAppHostAuxiliaryBackchannel connection,
        string resourceName,
        string status,
        int timeoutSeconds,
        CancellationToken cancellationToken)
    {
        var statusLabel = GetStatusLabel(status);
 
        _logger.LogDebug("Waiting for resource '{ResourceName}' to reach status '{Status}' with timeout {Timeout}s", resourceName, status, timeoutSeconds);
 
        var startTimestamp = _timeProvider.GetTimestamp();
 
        var exitCode = await InteractionService.ShowStatusAsync(
            string.Format(CultureInfo.CurrentCulture, WaitCommandStrings.WaitingForResource, resourceName, statusLabel),
            (Func<Task<int>>)(async () =>
            {
                var response = await _resourceWaitService.WaitAsync(
                    connection,
                    resourceName,
                    GetWaitTarget(status),
                    timeoutSeconds,
                    cancellationToken).ConfigureAwait(false);
 
                if (response.Outcome == ResourceWaitOutcome.Success)
                {
                    return CliExitCodes.Success;
                }
 
                if (response.ResourceNotFound)
                {
                    InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, WaitCommandStrings.ResourceNotFound, resourceName));
                    return CliExitCodes.WaitResourceFailed;
                }
 
                if (response.Outcome == ResourceWaitOutcome.Timeout)
                {
                    InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, WaitCommandStrings.WaitTimedOut, resourceName, statusLabel, timeoutSeconds));
                    return CliExitCodes.WaitTimeout;
                }
 
                // Resource entered a failed state
                InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, WaitCommandStrings.ResourceEnteredFailedState, resourceName, response.State ?? response.ErrorMessage));
                return CliExitCodes.WaitResourceFailed;
            }));
 
        // Reset cursor position after spinner
        InteractionService.DisplayPlainText("");
 
        if (exitCode == CliExitCodes.Success)
        {
            var elapsed = _timeProvider.GetElapsedTime(startTimestamp);
            InteractionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, WaitCommandStrings.ResourceReachedTargetStatus, resourceName, statusLabel, elapsed.TotalSeconds));
        }
 
        return exitCode;
    }
 
    private static bool IsValidStatus(string status)
    {
        return status is "healthy" or "up" or "down";
    }
 
    private static string GetStatusLabel(string status)
    {
        return status switch
        {
            "up" => "up (running)",
            "healthy" => "healthy",
            "down" => "down",
            _ => status
        };
    }
 
    private static ResourceWaitTarget GetWaitTarget(string status)
    {
        return status switch
        {
            "healthy" => ResourceWaitTarget.Healthy,
            "up" => ResourceWaitTarget.Up,
            "down" => ResourceWaitTarget.Down,
            _ => throw new ArgumentOutOfRangeException(nameof(status))
        };
    }
}