File: ApplicationModel\CommandsConfigurationExtensions.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using Aspire.Hosting.Orchestrator;
using Aspire.Hosting.Resources;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.ApplicationModel;
 
internal static class CommandsConfigurationExtensions
{
    private const string BuildLogPrefix = "[build] ";
    internal static void AddLifeCycleCommands(this IResource resource)
    {
        if (resource.TryGetLastAnnotation<ExcludeLifecycleCommandsAnnotation>(out _))
        {
            return;
        }
 
        resource.Annotations.Add(new ResourceCommandAnnotation(
            name: KnownResourceCommands.StartCommand,
            displayName: CommandStrings.StartName,
            executeCommand: async context =>
            {
                var orchestrator = context.ServiceProvider.GetRequiredService<ApplicationOrchestrator>();
 
                await orchestrator.StartResourceAsync(context.ResourceName, context.CancellationToken).ConfigureAwait(false);
                return CommandResults.Success();
            },
            updateState: context =>
            {
                var state = context.ResourceSnapshot.State?.Text;
                if (IsStarting(state) || IsBuilding(state) || IsRuntimeUnhealthy(state) || HasNoState(state))
                {
                    return ResourceCommandState.Disabled;
                }
                else if (IsStopped(state) || IsWaiting(state))
                {
                    return ResourceCommandState.Enabled;
                }
                else
                {
                    return ResourceCommandState.Hidden;
                }
            },
            displayDescription: CommandStrings.StartDescription,
            parameter: null,
            confirmationMessage: null,
            iconName: "Play",
            iconVariant: IconVariant.Filled,
            isHighlighted: true));
 
        resource.Annotations.Add(new ResourceCommandAnnotation(
            name: KnownResourceCommands.StopCommand,
            displayName: CommandStrings.StopName,
            executeCommand: async context =>
            {
                var orchestrator = context.ServiceProvider.GetRequiredService<ApplicationOrchestrator>();
 
                await orchestrator.StopResourceAsync(context.ResourceName, context.CancellationToken).ConfigureAwait(false);
                return CommandResults.Success();
            },
            updateState: context =>
            {
                var state = context.ResourceSnapshot.State?.Text;
                if (IsStopping(state))
                {
                    return ResourceCommandState.Disabled;
                }
                else if (!IsStopped(state) && !IsStarting(state) && !IsWaiting(state) && !IsBuilding(state) && !IsRuntimeUnhealthy(state) && !HasNoState(state))
                {
                    return ResourceCommandState.Enabled;
                }
                else
                {
                    return ResourceCommandState.Hidden;
                }
            },
            displayDescription: CommandStrings.StopDescription,
            parameter: null,
            confirmationMessage: null,
            iconName: "Stop",
            iconVariant: IconVariant.Filled,
            isHighlighted: true));
 
        // Use a more detailed description for .NET projects to help AI understand
        // that source code changes won't take effect until rebuilding the project.
        var restartDescription = resource is ProjectResource
            ? CommandStrings.RestartProjectDescription
            : CommandStrings.RestartDescription;
 
        resource.Annotations.Add(new ResourceCommandAnnotation(
            name: KnownResourceCommands.RestartCommand,
            displayName: CommandStrings.RestartName,
            executeCommand: async context =>
            {
                var orchestrator = context.ServiceProvider.GetRequiredService<ApplicationOrchestrator>();
 
                await orchestrator.StopResourceAsync(context.ResourceName, context.CancellationToken).ConfigureAwait(false);
                await orchestrator.StartResourceAsync(context.ResourceName, context.CancellationToken).ConfigureAwait(false);
                return CommandResults.Success();
            },
            updateState: context =>
            {
                var state = context.ResourceSnapshot.State?.Text;
                if (IsStarting(state) || IsStopping(state) || IsStopped(state) || IsWaiting(state) || IsBuilding(state) || IsRuntimeUnhealthy(state) || HasNoState(state))
                {
                    return ResourceCommandState.Disabled;
                }
                else
                {
                    return ResourceCommandState.Enabled;
                }
            },
            displayDescription: restartDescription,
            parameter: null,
            confirmationMessage: null,
            iconName: "ArrowCounterclockwise",
            iconVariant: IconVariant.Regular,
            isHighlighted: false));
 
        if (resource is ProjectResource projectResource)
        {
            var projectMetadata = projectResource.Annotations.OfType<IProjectMetadata>().SingleOrDefault();
            if (projectMetadata is null || !projectMetadata.IsFileBasedApp)
            {
                AddRebuildCommand(projectResource);
            }
        }
 
        // Treat "Unknown" as stopped so the command to start the resource is available when "Unknown".
        // There is a situation where a container can be stopped with this state: https://github.com/microsoft/aspire/issues/5977
        static bool IsStopped(string? state) => KnownResourceStates.TerminalStates.Contains(state) || state == KnownResourceStates.NotStarted || state == "Unknown";
        static bool IsStopping(string? state) => state == KnownResourceStates.Stopping;
        static bool IsStarting(string? state) => state == KnownResourceStates.Starting;
        static bool IsWaiting(string? state) => state == KnownResourceStates.Waiting;
        static bool IsBuilding(string? state) => state == KnownResourceStates.Building;
        static bool IsRuntimeUnhealthy(string? state) => state == KnownResourceStates.RuntimeUnhealthy;
        static bool HasNoState(string? state) => string.IsNullOrEmpty(state);
    }
 
    private static void AddRebuildCommand(ProjectResource projectResource)
    {
        // When a resource has replicas, the command framework invokes the handler
        // once per replica in parallel. We use a shared task so only a single build
        // runs and every replica handler awaits the same result.
        Task<ExecuteCommandResult>? activeRebuildTask = null;
        var rebuildLock = new object();
 
        projectResource.Annotations.Add(new ResourceCommandAnnotation(
            name: KnownResourceCommands.RebuildCommand,
            displayName: CommandStrings.RebuildName,
            executeCommand: context =>
            {
                lock (rebuildLock)
                {
                    if (activeRebuildTask is null || activeRebuildTask.IsCompleted)
                    {
                        activeRebuildTask = ExecuteRebuildAsync(context, projectResource);
                    }
                }
 
                return activeRebuildTask;
            },
            updateState: context =>
            {
                var state = context.ResourceSnapshot.State?.Text;
                return state is not null && KnownResourceStates.BuildableStates.Contains(state)
                    ? ResourceCommandState.Enabled
                    : ResourceCommandState.Disabled;
            },
            displayDescription: CommandStrings.RebuildDescription,
            parameter: null,
            confirmationMessage: null,
            iconName: "ArrowSync",
            iconVariant: IconVariant.Regular,
            isHighlighted: false));
    }
 
    private static async Task<ExecuteCommandResult> ExecuteRebuildAsync(ExecuteCommandContext context, ProjectResource projectResource)
    {
        var orchestrator = context.ServiceProvider.GetRequiredService<ApplicationOrchestrator>();
        var resourceNotificationService = context.ServiceProvider.GetRequiredService<ResourceNotificationService>();
        var loggerService = context.ServiceProvider.GetRequiredService<ResourceLoggerService>();
        var model = context.ServiceProvider.GetRequiredService<DistributedApplicationModel>();
 
        var rebuilderResource = model.Resources.OfType<ProjectRebuilderResource>().FirstOrDefault(r => r.Parent == projectResource);
        if (rebuilderResource is null)
        {
            return new ExecuteCommandResult { Success = false, ErrorMessage = string.Format(CultureInfo.InvariantCulture, CommandStrings.RebuilderResourceNotFound, projectResource.Name) };
        }
 
        var mainLogger = loggerService.GetLogger(projectResource);
        var replicaNames = projectResource.GetResolvedResourceNames();
 
        // Capture each replica's state before rebuild so we can restore inactive replicas.
        var preRebuildStates = new Dictionary<string, string?>(StringComparer.Ordinal);
        foreach (var name in replicaNames)
        {
            if (resourceNotificationService.TryGetCurrentState(name, out var evt))
            {
                preRebuildStates[name] = evt.Snapshot.State?.Text;
            }
        }
 
        // Stop non-waiting replicas. Waiting replicas have no running process — their DCP
        // lifecycle is blocked at WaitForInBeforeResourceStartedEvent waiting for dependencies.
        // Attempting to stop them would fail because no DCP Executable has been created yet.
        var replicasToStop = replicaNames.Where(name =>
            !preRebuildStates.TryGetValue(name, out var state)
            || state != KnownResourceStates.Waiting);
 
        mainLogger.LogInformation(BuildLogPrefix + "Stopping resource for rebuild...");
        await Task.WhenAll(replicasToStop.Select(name => orchestrator.StopResourceAsync(name, context.CancellationToken))).ConfigureAwait(false);
 
        // Set state to Building after replicas are stopped. Leave Waiting replicas in their
        // current state — changing their state text would unblock WaitForInBeforeResourceStartedEvent,
        // causing CreateExecutableAsync to launch the OLD binary while the build is in progress.
        await resourceNotificationService.PublishUpdateAsync(projectResource, s =>
            s.State?.Text == KnownResourceStates.Waiting
                ? s
                : s with { State = new ResourceStateSnapshot(KnownResourceStates.Building, KnownResourceStateStyles.Info) }
        ).ConfigureAwait(false);
 
        // Start forwarding logs from the rebuilder to the main resource's console.
        using var logCts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken);
        var rebuilderInstanceName = rebuilderResource.GetResolvedResourceNames()[0];
        var logForwardTask = ForwardLogsAsync(loggerService, rebuilderInstanceName, mainLogger, logCts.Token);
 
        try
        {
            // Start the rebuilder resource (runs dotnet build).
            mainLogger.LogInformation(BuildLogPrefix + "Building project...");
            await orchestrator.StartResourceAsync(rebuilderInstanceName, context.CancellationToken).ConfigureAwait(false);
 
            // Wait for the rebuilder to reach a terminal state, with a timeout.
            int? exitCode = null;
            using var buildTimeoutCts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken);
            buildTimeoutCts.CancelAfter(TimeSpan.FromMinutes(10));
 
            try
            {
                await foreach (var evt in resourceNotificationService.WatchAsync(buildTimeoutCts.Token).ConfigureAwait(false))
                {
                    if (evt.Resource == rebuilderResource &&
                        KnownResourceStates.TerminalStates.Contains(evt.Snapshot.State?.Text))
                    {
                        exitCode = evt.Snapshot.ExitCode;
                        break;
                    }
                }
            }
            catch (OperationCanceledException) when (!context.CancellationToken.IsCancellationRequested)
            {
                // Build timed out.
                mainLogger.LogError(BuildLogPrefix + "Build timed out.");
 
                await resourceNotificationService.PublishUpdateAsync(projectResource, s => s with
                {
                    State = new ResourceStateSnapshot(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error)
                }).ConfigureAwait(false);
                return new ExecuteCommandResult { Success = false, ErrorMessage = "Build timed out." };
            }
 
            if (exitCode == 0)
            {
                // Restart replicas that were Running before the rebuild.
                // Waiting replicas are already in the startup pipeline — when their deps become
                // ready, CreateExecutableAsync will launch the freshly-built binary automatically.
                mainLogger.LogInformation(BuildLogPrefix + "Build succeeded. Restarting resource...");
                var anyRestarted = false;
                foreach (var name in replicaNames)
                {
                    var wasActive = preRebuildStates.TryGetValue(name, out var priorState)
                        && priorState == KnownResourceStates.Running;
 
                    var wasWaiting = preRebuildStates.TryGetValue(name, out var waitState)
                        && waitState == KnownResourceStates.Waiting;
 
                    if (wasWaiting)
                    {
                        // The resource is still waiting for dependencies. The build output on disk
                        // has been updated, so when dependencies become ready the new binary will
                        // be launched. Log a message so the user knows the build succeeded.
                        mainLogger.LogInformation(BuildLogPrefix + "Build succeeded. Resource will start with the updated binary when dependencies are ready.");
                        anyRestarted = true;
                    }
                    else if (wasActive)
                    {
                        anyRestarted = true;
                        await resourceNotificationService.PublishUpdateAsync(projectResource, name, s => s with
                        {
                            State = new ResourceStateSnapshot(KnownResourceStates.Starting, KnownResourceStateStyles.Info)
                        }).ConfigureAwait(false);
 
                        await orchestrator.StartResourceAsync(name, context.CancellationToken).ConfigureAwait(false);
                    }
                }
 
                if (!anyRestarted)
                {
                    // No replicas were running before rebuild (e.g. resource was stopped).
                    // Restore each replica to its pre-build state so it doesn't stay stuck
                    // in "Building" indefinitely.
                    foreach (var name in replicaNames)
                    {
                        if (preRebuildStates.TryGetValue(name, out var priorState) && priorState is not null)
                        {
                            await resourceNotificationService.PublishUpdateAsync(projectResource, name, s => s with
                            {
                                State = new ResourceStateSnapshot(priorState, KnownResourceStateStyles.Info)
                            }).ConfigureAwait(false);
                        }
                    }
                }
 
                return CommandResults.Success();
            }
            else
            {
                mainLogger.LogError(BuildLogPrefix + "Build failed with exit code {ExitCode}.", exitCode);
                await resourceNotificationService.PublishUpdateAsync(projectResource, s => s with
                {
                    State = new ResourceStateSnapshot(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error)
                }).ConfigureAwait(false);
                return new ExecuteCommandResult { Success = false, ErrorMessage = $"Build failed with exit code {exitCode}." };
            }
        }
        catch (OperationCanceledException) when (context.CancellationToken.IsCancellationRequested)
        {
            // The command was cancelled (e.g. user navigated away or the dashboard closed).
            // The replicas were already stopped for the rebuild, so set them to Exited.
            mainLogger.LogWarning(BuildLogPrefix + "Rebuild was cancelled.");
            await resourceNotificationService.PublishUpdateAsync(projectResource, s => s with
            {
                State = new ResourceStateSnapshot(KnownResourceStates.Finished, KnownResourceStateStyles.Info)
            }).ConfigureAwait(false);
            return new ExecuteCommandResult { Success = false, ErrorMessage = "Rebuild was cancelled." };
        }
        finally
        {
            await StopLogForwardingAsync(logCts, logForwardTask).ConfigureAwait(false);
        }
    }
 
    private static async Task StopLogForwardingAsync(CancellationTokenSource logCts, Task logForwardTask)
    {
        await logCts.CancelAsync().ConfigureAwait(false);
 
        try
        {
            await logForwardTask.ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
            // Expected when cancelling the log forwarder.
        }
    }
 
    private static async Task ForwardLogsAsync(ResourceLoggerService loggerService, string sourceResourceName, ILogger targetLogger, CancellationToken cancellationToken)
    {
        try
        {
            await foreach (var batch in loggerService.WatchAsync(sourceResourceName).WithCancellation(cancellationToken).ConfigureAwait(false))
            {
                foreach (var line in batch)
                {
                    if (line.IsErrorMessage)
                    {
                        targetLogger.LogWarning(BuildLogPrefix + "{Content}", line.Content);
                    }
                    else
                    {
                        targetLogger.LogInformation(BuildLogPrefix + "{Content}", line.Content);
                    }
                }
            }
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            // Expected when the log forwarding is cancelled.
        }
    }
}