diff --git a/Directory.Packages.props b/Directory.Packages.props index cee1bf84d1..18e97cfdd6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -109,6 +109,7 @@ + diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor index 43d4768e14..1e6b893699 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor @@ -24,7 +24,7 @@ } else { - + @item.Text @if (item.Icon != null) { diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor index fe765ef725..a47e48ea32 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor +++ b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor @@ -2,12 +2,12 @@ @using Aspire.Dashboard.Model @using Microsoft.FluentUI.AspNetCore.Components -@foreach (var highlightedCommand in Commands.Where(c => c.IsHighlighted)) +@foreach (var highlightedCommand in Commands.Where(c => c.IsHighlighted && c.State != CommandViewModelState.Hidden)) { - + @if (!string.IsNullOrEmpty(highlightedCommand.IconName) && CommandViewModel.ResolveIconName(highlightedCommand.IconName) is { } icon) { - + } else { diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs index 9ad8e812c3..1b54965cfa 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs @@ -49,7 +49,7 @@ protected override void OnParametersSet() OnClick = OnConsoleLogs.InvokeAsync }); - var menuCommands = Commands.Where(c => !c.IsHighlighted).ToList(); + var menuCommands = Commands.Where(c => !c.IsHighlighted && c.State != CommandViewModelState.Hidden).ToList(); if (menuCommands.Count > 0) { _menuItems.Add(new MenuButtonItem { IsDivider = true }); @@ -63,7 +63,8 @@ protected override void OnParametersSet() Text = command.DisplayName, Tooltip = command.DisplayDescription, Icon = icon, - OnClick = () => CommandSelected.InvokeAsync(command) + OnClick = () => CommandSelected.InvokeAsync(command), + IsDisabled = command.State == CommandViewModelState.Disabled }); } } diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs index 9f717dcc6e..b55806635b 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs @@ -312,16 +312,18 @@ private async Task ExecuteResourceCommandAsync(ResourceViewModel resource, Comma var response = await DashboardClient.ExecuteResourceCommandAsync(resource.Name, resource.ResourceType, command, CancellationToken.None); + var messageResourceName = GetResourceName(resource); + if (response.Kind == ResourceCommandResponseKind.Succeeded) { - ToastService.ShowSuccess(string.Format(CultureInfo.InvariantCulture, Loc[nameof(Dashboard.Resources.Resources.ResourceCommandSuccess)], command.DisplayName)); + ToastService.ShowSuccess(string.Format(CultureInfo.InvariantCulture, Loc[nameof(Dashboard.Resources.Resources.ResourceCommandSuccess)], command.DisplayName + " " + messageResourceName)); } else { ToastService.ShowCommunicationToast(new ToastParameters() { Intent = ToastIntent.Error, - Title = string.Format(CultureInfo.InvariantCulture, Loc[nameof(Dashboard.Resources.Resources.ResourceCommandFailed)], command.DisplayName), + Title = string.Format(CultureInfo.InvariantCulture, Loc[nameof(Dashboard.Resources.Resources.ResourceCommandFailed)], command.DisplayName + " " + messageResourceName), PrimaryAction = Loc[nameof(Dashboard.Resources.Resources.ResourceCommandToastViewLogs)], OnPrimaryAction = EventCallback.Factory.Create(this, () => NavigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: resource.Name))), Content = new CommunicationToastContent() diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/StateColumnDisplay.razor b/src/Aspire.Dashboard/Components/ResourcesGridColumns/StateColumnDisplay.razor index 3374c26b3f..176042ea31 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/StateColumnDisplay.razor +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/StateColumnDisplay.razor @@ -34,7 +34,7 @@ Class="severity-icon" /> } } -else if (Resource.IsStartingOrBuildingOrWaiting()) +else if (Resource.IsUnusableTransitoryState()) { string.IsNullOrEmpty(resource.State); diff --git a/src/Aspire.Dashboard/Model/KnownResourceState.cs b/src/Aspire.Dashboard/Model/KnownResourceState.cs index cb62a66a89..83f6b0e27c 100644 --- a/src/Aspire.Dashboard/Model/KnownResourceState.cs +++ b/src/Aspire.Dashboard/Model/KnownResourceState.cs @@ -12,5 +12,6 @@ public enum KnownResourceState Running, Building, Hidden, - Waiting + Waiting, + Stopping } diff --git a/src/Aspire.Dashboard/Model/MenuButtonItem.cs b/src/Aspire.Dashboard/Model/MenuButtonItem.cs index 14eaecb22d..88ef16e7c6 100644 --- a/src/Aspire.Dashboard/Model/MenuButtonItem.cs +++ b/src/Aspire.Dashboard/Model/MenuButtonItem.cs @@ -12,4 +12,5 @@ public class MenuButtonItem public string? Tooltip { get; set; } public Icon? Icon { get; set; } public Func? OnClick { get; set; } + public bool IsDisabled { get; set; } } diff --git a/src/Aspire.Dashboard/Model/ResourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceViewModel.cs index 0de2f04d59..6ee45b905a 100644 --- a/src/Aspire.Dashboard/Model/ResourceViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceViewModel.cs @@ -74,6 +74,7 @@ public sealed class CommandViewModel private static readonly ConcurrentDictionary s_iconCache = new(); public string CommandType { get; } + public CommandViewModelState State { get; } public string DisplayName { get; } public string? DisplayDescription { get; } public string? ConfirmationMessage { get; } @@ -81,12 +82,13 @@ public sealed class CommandViewModel public bool IsHighlighted { get; } public string? IconName { get; } - public CommandViewModel(string commandType, string displayName, string? displayDescription, string? confirmationMessage, Value? parameter, bool isHighlighted, string? iconName) + public CommandViewModel(string commandType, CommandViewModelState state, string displayName, string? displayDescription, string? confirmationMessage, Value? parameter, bool isHighlighted, string? iconName) { ArgumentException.ThrowIfNullOrWhiteSpace(commandType); ArgumentException.ThrowIfNullOrWhiteSpace(displayName); CommandType = commandType; + State = state; DisplayName = displayName; DisplayDescription = displayDescription; ConfirmationMessage = confirmationMessage; @@ -118,6 +120,13 @@ public CommandViewModel(string commandType, string displayName, string? displayD } } +public enum CommandViewModelState +{ + Enabled, + Disabled, + Hidden +} + [DebuggerDisplay("Name = {Name}, Value = {Value}, FromSpec = {FromSpec}, IsValueMasked = {IsValueMasked}")] public sealed class EnvironmentVariableViewModel { diff --git a/src/Aspire.Dashboard/ResourceService/Partials.cs b/src/Aspire.Dashboard/ResourceService/Partials.cs index d9a4104a58..3871c31b99 100644 --- a/src/Aspire.Dashboard/ResourceService/Partials.cs +++ b/src/Aspire.Dashboard/ResourceService/Partials.cs @@ -71,8 +71,18 @@ ImmutableArray GetVolumes() ImmutableArray GetCommands() { return Commands - .Select(c => new CommandViewModel(c.CommandType, c.DisplayName, c.HasDisplayDescription ? c.DisplayDescription : null, c.ConfirmationMessage, c.Parameter, c.IsHighlighted, c.HasIconName ? c.IconName : null)) + .Select(c => new CommandViewModel(c.CommandType, Map(c.State), c.DisplayName, c.HasDisplayDescription ? c.DisplayDescription : null, c.ConfirmationMessage, c.Parameter, c.IsHighlighted, c.HasIconName ? c.IconName : null)) .ToImmutableArray(); + static CommandViewModelState Map(ResourceCommandState state) + { + return state switch + { + ResourceCommandState.Enabled => CommandViewModelState.Enabled, + ResourceCommandState.Disabled => CommandViewModelState.Disabled, + ResourceCommandState.Hidden => CommandViewModelState.Hidden, + _ => throw new InvalidOperationException("Unknown state: " + state), + }; + } } T ValidateNotNull(T value, [CallerArgumentExpression(nameof(value))] string? expression = null) where T : class diff --git a/src/Aspire.Hosting/ApplicationModel/CommandsConfigurationExtensions.cs b/src/Aspire.Hosting/ApplicationModel/CommandsConfigurationExtensions.cs new file mode 100644 index 0000000000..f20667a1ec --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/CommandsConfigurationExtensions.cs @@ -0,0 +1,105 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Dcp; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.ApplicationModel; + +internal static class CommandsConfigurationExtensions +{ + internal const string StartType = "start"; + internal const string StopType = "stop"; + internal const string RestartType = "restart"; + + internal static IResourceBuilder WithLifeCycleCommands(this IResourceBuilder builder) where T : IResource + { + builder.WithCommand( + type: StartType, + displayName: "Start", + executeCommand: async context => + { + var executor = context.ServiceProvider.GetRequiredService(); + + await executor.StartResourceAsync(context.ResourceName, context.CancellationToken).ConfigureAwait(false); + return CommandResults.Success(); + }, + updateState: context => + { + if (IsStarting(context.ResourceSnapshot.State?.Text)) + { + return ResourceCommandState.Disabled; + } + else if (IsStopped(context.ResourceSnapshot.State?.Text)) + { + return ResourceCommandState.Enabled; + } + else + { + return ResourceCommandState.Hidden; + } + }, + iconName: "Play", + isHighlighted: true); + + builder.WithCommand( + type: StopType, + displayName: "Stop", + executeCommand: async context => + { + var executor = context.ServiceProvider.GetRequiredService(); + + await executor.StopResourceAsync(context.ResourceName, context.CancellationToken).ConfigureAwait(false); + return CommandResults.Success(); + }, + updateState: context => + { + if (IsWaiting(context.ResourceSnapshot.State?.Text) || IsStopping(context.ResourceSnapshot.State?.Text)) + { + return ResourceCommandState.Disabled; + } + else if (!IsStopped(context.ResourceSnapshot.State?.Text) && !IsStarting(context.ResourceSnapshot.State?.Text)) + { + return ResourceCommandState.Enabled; + } + else + { + return ResourceCommandState.Hidden; + } + }, + iconName: "Stop", + isHighlighted: true); + + builder.WithCommand( + type: RestartType, + displayName: "Restart", + executeCommand: async context => + { + var executor = context.ServiceProvider.GetRequiredService(); + + await executor.StopResourceAsync(context.ResourceName, context.CancellationToken).ConfigureAwait(false); + await executor.StartResourceAsync(context.ResourceName, context.CancellationToken).ConfigureAwait(false); + return CommandResults.Success(); + }, + updateState: context => + { + if (IsWaiting(context.ResourceSnapshot.State?.Text) || IsStarting(context.ResourceSnapshot.State?.Text) || IsStopping(context.ResourceSnapshot.State?.Text) || IsStopped(context.ResourceSnapshot.State?.Text)) + { + return ResourceCommandState.Disabled; + } + else + { + return ResourceCommandState.Enabled; + } + }, + iconName: "ArrowCounterclockwise", + isHighlighted: false); + + return builder; + + static bool IsStopped(string? state) => state is "Exited" or "Finished" or "FailedToStart"; + static bool IsStopping(string? state) => state is "Stopping"; + static bool IsStarting(string? state) => state is "Starting"; + static bool IsWaiting(string? state) => state is "Waiting"; + } +} diff --git a/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs index ea17df8f95..29dfb3559a 100644 --- a/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs +++ b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs @@ -56,6 +56,11 @@ public sealed record CustomResourceSnapshot /// The volumes that should show up in the dashboard for this resource. /// public ImmutableArray Volumes { get; init; } = []; + + /// + /// The commands available in the dashboard for this resource. + /// + public ImmutableArray Commands { get; init; } = []; } /// @@ -105,6 +110,35 @@ public sealed record VolumeSnapshot(string? Source, string Target, string MountT /// The value of the property. public sealed record ResourcePropertySnapshot(string Name, object? Value); +/// +/// A snapshot of a resource command. +/// +/// The type of command. The type uniquely identifies the command. +/// The state of the command. +/// The display name visible in UI for the command. +/// The icon name for the command. The name should be a valid FluentUI icon name. https://aka.ms/fluentui-system-icons +/// A flag indicating whether the command is highlighted in the UI. +public sealed record ResourceCommandSnapshot(string Type, ResourceCommandState State, string DisplayName, string? IconName, bool IsHighlighted); + +/// +/// The state of a resource command. +/// +public enum ResourceCommandState +{ + /// + /// Command is visible and enabled for use. + /// + Enabled, + /// + /// Command is visible and disabled for use. + /// + Disabled, + /// + /// Command is hidden. + /// + Hidden +} + /// /// The set of well known resource states. /// diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs new file mode 100644 index 0000000000..94961fdb9e --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a command annotation for a resource. +/// +[DebuggerDisplay("Type = {GetType().Name,nq}, Type = {Type}")] +public sealed class ResourceCommandAnnotation : IResourceAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + public ResourceCommandAnnotation( + string type, + string displayName, + Func updateState, + Func> executeCommand, + string? iconName, + bool isHighlighted) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(displayName); + ArgumentNullException.ThrowIfNull(updateState); + ArgumentNullException.ThrowIfNull(executeCommand); + + Type = type; + DisplayName = displayName; + UpdateState = updateState; + ExecuteCommand = executeCommand; + IconName = iconName; + IsHighlighted = isHighlighted; + } + + /// + /// The type of command. The type uniquely identifies the command. + /// + public string Type { get; } + + /// + /// The display name visible in UI. + /// + public string DisplayName { get; } + + /// + /// A callback that is used to update the command state. + /// The callback is executed when the command's resource snapshot is updated. + /// + public Func UpdateState { get; } + + /// + /// A callback that is executed when the command is executed. + /// The result is used to indicate success or failure in the UI. + /// + public Func> ExecuteCommand { get; } + + /// + /// The icon name for the command. The name should be a valid FluentUI icon name. https://aka.ms/fluentui-system-icons + /// + public string? IconName { get; } + + /// + /// A flag indicating whether the command is highlighted in the UI. + /// + public bool IsHighlighted { get; } +} + +/// +/// A factory for . +/// +public static class CommandResults +{ + /// + /// Produces a success result. + /// + public static ExecuteCommandResult Success() => new ExecuteCommandResult { Success = true }; +} + +/// +/// The result of executing a command. Returned from . +/// +public class ExecuteCommandResult +{ + /// + /// A flag that indicates whether the command was successful. + /// + public required bool Success { get; init; } + + /// + /// An optional error message that can be set when the command is unsuccessful. + /// + public string? ErrorMessage { get; init; } +} + +/// +/// Context for . +/// +public class UpdateCommandStateContext +{ + /// + /// The resource snapshot. + /// + public required CustomResourceSnapshot ResourceSnapshot { get; init; } + + /// + /// The service provider. + /// + public required IServiceProvider ServiceProvider { get; init; } +} + +/// +/// Context for . +/// +public class ExecuteCommandContext +{ + /// + /// The service provider. + /// + public required IServiceProvider ServiceProvider { get; init; } + + /// + /// The resource name. + /// + public required string ResourceName { get; init; } + + /// + /// The cancellation token. + /// + public required CancellationToken CancellationToken { get; init; } +} diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs index eef0cfcb86..7b4b7ef6ed 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Concurrent; +using System.Collections.Immutable; using System.Runtime.CompilerServices; using System.Threading.Channels; using Microsoft.Extensions.Hosting; @@ -17,6 +18,7 @@ public class ResourceNotificationService // Resource state is keyed by the resource and the unique name of the resource. This could be the name of the resource, or a replica ID. private readonly ConcurrentDictionary<(IResource, string), ResourceNotificationState> _resourceNotificationStates = new(); private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; private readonly CancellationToken _applicationStopping; private Action? OnResourceUpdated { get; set; } @@ -25,18 +27,21 @@ public class ResourceNotificationService /// Creates a new instance of . /// /// - /// Obsolete. Use the constructor that accepts an and .
+ /// Obsolete. Use the constructor that accepts an , and .
/// This constructor will be removed in the next major version of Aspire. ///
/// The logger. + /// The host application lifetime. [Obsolete($""" - {nameof(ResourceNotificationService)} now requires an {nameof(IHostApplicationLifetime)}. - Use the constructor that accepts an {nameof(ILogger)}<{nameof(ResourceNotificationService)}> and {nameof(IHostApplicationLifetime)}. + {nameof(ResourceNotificationService)} now requires an {nameof(IServiceProvider)}. + Use the constructor that accepts an {nameof(ILogger)}<{nameof(ResourceNotificationService)}>, {nameof(IHostApplicationLifetime)} and {nameof(IServiceProvider)}. This constructor will be removed in the next major version of Aspire. """)] - public ResourceNotificationService(ILogger logger) + public ResourceNotificationService(ILogger logger, IHostApplicationLifetime hostApplicationLifetime) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _serviceProvider = new NullServiceProvider(); + _applicationStopping = hostApplicationLifetime?.ApplicationStopping ?? throw new ArgumentNullException(nameof(hostApplicationLifetime)); } /// @@ -44,12 +49,19 @@ public ResourceNotificationService(ILogger logger) /// /// The logger. /// The host application lifetime. - public ResourceNotificationService(ILogger logger, IHostApplicationLifetime hostApplicationLifetime) + /// The service provider. + public ResourceNotificationService(ILogger logger, IHostApplicationLifetime hostApplicationLifetime, IServiceProvider serviceProvider) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _serviceProvider = serviceProvider; _applicationStopping = hostApplicationLifetime?.ApplicationStopping ?? throw new ArgumentNullException(nameof(hostApplicationLifetime)); } + private class NullServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + /// /// Waits for a resource to reach the specified state. See for common states. /// @@ -197,6 +209,8 @@ public Task PublishUpdateAsync(IResource resource, string resourceId, Func $"{e.Name} = {e.Value}")), string.Join(", ", newState.Urls.Select(u => $"{u.Name} = {u.Url}")), string.Join(", ", newState.Properties.Select(p => $"{p.Name} = {p.Value}"))); } + } + + return Task.CompletedTask; + } + + /// + /// Use command annotations to update resource snapshot. + /// + private CustomResourceSnapshot UpdateCommands(IResource resource, CustomResourceSnapshot previousState) + { + ImmutableArray.Builder? builder = null; + + foreach (var annotation in resource.Annotations.OfType()) + { + var existingCommand = FindByType(previousState.Commands, annotation.Type); + + if (existingCommand == null) + { + if (builder == null) + { + builder = ImmutableArray.CreateBuilder(previousState.Commands.Length); + builder.AddRange(previousState.Commands); + } + + // Command doesn't exist in snapshot. Create from annotation. + builder.Add(CreateCommandFromAnnotation(annotation, previousState, _serviceProvider)); + } + else + { + // Command already exists in snapshot. Update its state based on annotation callback. + var newState = annotation.UpdateState(new UpdateCommandStateContext { ResourceSnapshot = previousState, ServiceProvider = _serviceProvider }); + + if (existingCommand.State != newState) + { + if (builder == null) + { + builder = ImmutableArray.CreateBuilder(previousState.Commands.Length); + builder.AddRange(previousState.Commands); + } + + var newCommand = existingCommand with + { + State = newState + }; + + builder.Replace(existingCommand, newCommand); + } + } + } + + // Commands are unchanged. Return unchanged state. + if (builder == null) + { + return previousState; + } + + return previousState with { Commands = builder.ToImmutable() }; + + static ResourceCommandSnapshot? FindByType(ImmutableArray commands, string type) + { + for (var i = 0; i < commands.Length; i++) + { + if (commands[i].Type == type) + { + return commands[i]; + } + } + + return null; + } + + static ResourceCommandSnapshot CreateCommandFromAnnotation(ResourceCommandAnnotation annotation, CustomResourceSnapshot previousState, IServiceProvider serviceProvider) + { + var state = annotation.UpdateState(new UpdateCommandStateContext { ResourceSnapshot = previousState, ServiceProvider = serviceProvider }); - return Task.CompletedTask; + return new ResourceCommandSnapshot(annotation.Type, state, annotation.DisplayName, annotation.IconName, annotation.IsHighlighted); } } diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 95c0667c37..9c53bd82fc 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -47,6 +47,7 @@ + diff --git a/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs b/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs index 21a702ed70..480c38eaeb 100644 --- a/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs @@ -150,6 +150,7 @@ public static IResourceBuilder WithImage(this IResourceBuilder builder, // if the annotation doesn't exist, create it with the given image and add it to the collection var containerImageAnnotation = new ContainerImageAnnotation() { Image = image, Tag = tag }; builder.Resource.Annotations.Add(containerImageAnnotation); + builder.WithLifeCycleCommands(); return builder; } diff --git a/src/Aspire.Hosting/Dashboard/DashboardCommandExecutor.cs b/src/Aspire.Hosting/Dashboard/DashboardCommandExecutor.cs new file mode 100644 index 0000000000..a03af98378 --- /dev/null +++ b/src/Aspire.Hosting/Dashboard/DashboardCommandExecutor.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Dashboard; + +/// +/// Used to execute annotations in the dashboard. +/// Although commands are received by the dashboard host, it's important that they're executed +/// in the context of the app host service provider. That allows commands to access user registered services. +/// +internal sealed class DashboardCommandExecutor +{ + private readonly IServiceProvider _appHostServiceProvider; + + public DashboardCommandExecutor(IServiceProvider appHostServiceProvider) + { + _appHostServiceProvider = appHostServiceProvider; + } + + public async Task ExecuteCommandAsync(string resourceId, ResourceCommandAnnotation annotation, CancellationToken cancellationToken) + { + var context = new ExecuteCommandContext + { + ResourceName = resourceId, + ServiceProvider = _appHostServiceProvider, + CancellationToken = cancellationToken + }; + + return await annotation.ExecuteCommand(context).ConfigureAwait(false); + } +} diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index a410a75d71..a0372ce2f5 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -130,6 +130,24 @@ async Task WatchResourceConsoleLogsInternal(CancellationToken cancellationToken) } } + public override async Task ExecuteResourceCommand(ResourceCommandRequest request, ServerCallContext context) + { + var (result, errorMessage) = await serviceData.ExecuteCommandAsync(request.ResourceName, request.CommandType, context.CancellationToken).ConfigureAwait(false); + var responseKind = result switch + { + DashboardServiceData.ExecuteCommandResult.Success => ResourceCommandResponseKind.Succeeded, + DashboardServiceData.ExecuteCommandResult.Canceled => ResourceCommandResponseKind.Cancelled, + DashboardServiceData.ExecuteCommandResult.Failure => ResourceCommandResponseKind.Failed, + _ => ResourceCommandResponseKind.Undefined + }; + + return new ResourceCommandResponse + { + Kind = responseKind, + ErrorMessage = errorMessage ?? string.Empty + }; + } + private async Task ExecuteAsync(Func execute, ServerCallContext serverCallContext) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(hostApplicationLifetime.ApplicationStopping, serverCallContext.CancellationToken); diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs index 413cdb8457..a431a48c1b 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs @@ -17,15 +17,18 @@ internal sealed class DashboardServiceData : IAsyncDisposable { private readonly CancellationTokenSource _cts = new(); private readonly ResourcePublisher _resourcePublisher; + private readonly DashboardCommandExecutor _commandExecutor; private readonly ResourceLoggerService _resourceLoggerService; public DashboardServiceData( ResourceNotificationService resourceNotificationService, ResourceLoggerService resourceLoggerService, - ILogger logger) + ILogger logger, + DashboardCommandExecutor commandExecutor) { _resourceLoggerService = resourceLoggerService; _resourcePublisher = new ResourcePublisher(_cts.Token); + _commandExecutor = commandExecutor; var cancellationToken = _cts.Token; @@ -51,7 +54,8 @@ static GenericResourceSnapshot CreateResourceSnapshot(IResource resource, string HealthStatus.Unhealthy => HealthStateKind.Unhealthy, HealthStatus.Degraded => HealthStateKind.Degraded, _ => HealthStateKind.Unknown, - } : null + } : null, + Commands = snapshot.Commands }; } @@ -68,7 +72,7 @@ static GenericResourceSnapshot CreateResourceSnapshot(IResource resource, string logger.LogDebug("Updating resource snapshot for {Name}/{DisplayName}: {State}", snapshot.Name, snapshot.DisplayName, snapshot.State); } - await _resourcePublisher.IntegrateAsync(snapshot, ResourceSnapshotChangeType.Upsert) + await _resourcePublisher.IntegrateAsync(@event.Resource, snapshot, ResourceSnapshotChangeType.Upsert) .ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) @@ -87,6 +91,49 @@ public async ValueTask DisposeAsync() _cts.Dispose(); } + internal async Task<(ExecuteCommandResult result, string? errorMessage)> ExecuteCommandAsync(string resourceId, string type, CancellationToken cancellationToken) + { + var logger = _resourceLoggerService.GetLogger(resourceId); + + logger.LogInformation("Executing command '{Type}'.", type); + if (_resourcePublisher.TryGetResource(resourceId, out _, out var resource)) + { + var annotation = resource.Annotations.OfType().SingleOrDefault(a => a.Type == type); + if (annotation != null) + { + try + { + var result = await _commandExecutor.ExecuteCommandAsync(resourceId, annotation, cancellationToken).ConfigureAwait(false); + if (result.Success) + { + logger.LogInformation("Successfully executed command '{Type}'.", type); + return (ExecuteCommandResult.Success, null); + } + else + { + logger.LogInformation("Failure executed command '{Type}'. Error message: {ErrorMessage}", type, result.ErrorMessage); + return (ExecuteCommandResult.Failure, result.ErrorMessage); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error executing command '{Type}'.", type); + return (ExecuteCommandResult.Failure, "Command throw an unhandled exception."); + } + } + } + + logger.LogInformation("Command '{Type}' not available.", type); + return (ExecuteCommandResult.Canceled, null); + } + + internal enum ExecuteCommandResult + { + Success, + Failure, + Canceled + } + internal ResourceSnapshotSubscription SubscribeResources() { return _resourcePublisher.Subscribe(); diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs index 36ba2b39d7..b37e3d4fce 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using System.Net; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Dcp; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -49,7 +48,7 @@ internal sealed class DashboardServiceHost : IHostedService public DashboardServiceHost( DistributedApplicationOptions options, DistributedApplicationModel applicationModel, - IKubernetesService kubernetesService, + DashboardCommandExecutor commandExecutor, IConfiguration configuration, DistributedApplicationExecutionContext executionContext, ILoggerFactory loggerFactory, @@ -109,7 +108,7 @@ public DashboardServiceHost( builder.Services.AddGrpc(); builder.Services.AddSingleton(applicationModel); - builder.Services.AddSingleton(kubernetesService); + builder.Services.AddSingleton(commandExecutor); builder.Services.AddSingleton(); builder.Services.AddSingleton(resourceNotificationService); builder.Services.AddSingleton(resourceLoggerService); diff --git a/src/Aspire.Hosting/Dashboard/ResourcePublisher.cs b/src/Aspire.Hosting/Dashboard/ResourcePublisher.cs index 479d5fd219..86aeeca91e 100644 --- a/src/Aspire.Hosting/Dashboard/ResourcePublisher.cs +++ b/src/Aspire.Hosting/Dashboard/ResourcePublisher.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading.Channels; +using Aspire.Hosting.ApplicationModel; namespace Aspire.Hosting.Dashboard; @@ -15,18 +16,29 @@ namespace Aspire.Hosting.Dashboard; /// internal sealed class ResourcePublisher(CancellationToken cancellationToken) { + private sealed record SourceAndResourceSnapshot(IResource Source, ResourceSnapshot Snapshot); + private readonly object _syncLock = new(); - private readonly Dictionary _snapshot = []; + private readonly Dictionary _snapshot = []; private ImmutableHashSet> _outgoingChannels = []; // For testing purposes internal int OutgoingSubscriberCount => _outgoingChannels.Count; - internal bool TryGetResource(string resourceName, [NotNullWhen(returnValue: true)] out ResourceSnapshot? resource) + internal bool TryGetResource(string resourceName, [NotNullWhen(returnValue: true)] out ResourceSnapshot? snapshot, [NotNullWhen(returnValue: true)] out IResource? resource) { lock (_syncLock) { - return _snapshot.TryGetValue(resourceName, out resource); + if (_snapshot.TryGetValue(resourceName, out var r)) + { + snapshot = r.Snapshot; + resource = r.Source; + return true; + } + + snapshot = null; + resource = null; + return false; } } @@ -40,7 +52,7 @@ public ResourceSnapshotSubscription Subscribe() ImmutableInterlocked.Update(ref _outgoingChannels, static (set, channel) => set.Add(channel), channel); return new ResourceSnapshotSubscription( - InitialState: _snapshot.Values.ToImmutableArray(), + InitialState: _snapshot.Select(r => r.Value.Snapshot).ToImmutableArray(), Subscription: StreamUpdates()); async IAsyncEnumerable> StreamUpdates([EnumeratorCancellation] CancellationToken enumeratorCancellationToken = default) @@ -65,10 +77,11 @@ async IAsyncEnumerable> StreamUpdates([Enu /// /// Integrates a changed resource within the cache, and broadcasts the update to any subscribers. /// - /// The resource that was modified. + /// The source resource. + /// The resource snapshot that was modified. /// The change type (Added, Modified, Deleted). /// A task that completes when the cache has been updated and all subscribers notified. - internal async ValueTask IntegrateAsync(ResourceSnapshot resource, ResourceSnapshotChangeType changeType) + internal async ValueTask IntegrateAsync(IResource source, ResourceSnapshot snapshot, ResourceSnapshotChangeType changeType) { ImmutableHashSet> channels; @@ -77,11 +90,11 @@ internal async ValueTask IntegrateAsync(ResourceSnapshot resource, ResourceSnaps switch (changeType) { case ResourceSnapshotChangeType.Upsert: - _snapshot[resource.Name] = resource; + _snapshot[snapshot.Name] = new SourceAndResourceSnapshot(source, snapshot); break; case ResourceSnapshotChangeType.Delete: - _snapshot.Remove(resource.Name); + _snapshot.Remove(snapshot.Name); break; } @@ -90,7 +103,7 @@ internal async ValueTask IntegrateAsync(ResourceSnapshot resource, ResourceSnaps foreach (var channel in channels) { - await channel.Writer.WriteAsync(new(changeType, resource), cancellationToken).ConfigureAwait(false); + await channel.Writer.WriteAsync(new(changeType, snapshot), cancellationToken).ConfigureAwait(false); } } } diff --git a/src/Aspire.Hosting/Dashboard/ResourceSnapshot.cs b/src/Aspire.Hosting/Dashboard/ResourceSnapshot.cs index f899c0177f..e76bc84401 100644 --- a/src/Aspire.Hosting/Dashboard/ResourceSnapshot.cs +++ b/src/Aspire.Hosting/Dashboard/ResourceSnapshot.cs @@ -25,6 +25,7 @@ internal abstract class ResourceSnapshot public required ImmutableArray Volumes { get; init; } public required ImmutableArray Urls { get; init; } public required HealthStateKind? HealthState { get; set; } + public required ImmutableArray Commands { get; init; } protected abstract IEnumerable<(string Key, Value Value)> GetProperties(); diff --git a/src/Aspire.Hosting/Dashboard/proto/Partials.cs b/src/Aspire.Hosting/Dashboard/proto/Partials.cs index ffe7ee8c9a..42b4f84eaa 100644 --- a/src/Aspire.Hosting/Dashboard/proto/Partials.cs +++ b/src/Aspire.Hosting/Dashboard/proto/Partials.cs @@ -10,7 +10,7 @@ partial class Resource { public static Resource FromSnapshot(ResourceSnapshot snapshot) { - Resource resource = new() + var resource = new Resource { Name = snapshot.Name, ResourceType = snapshot.ResourceType, @@ -56,47 +56,23 @@ public static Resource FromSnapshot(ResourceSnapshot snapshot) }); } - // Disable start/stop/restart commands until host/DCP infrastructure is ready. - /* - if (snapshot.ResourceType is KnownResourceTypes.Project or KnownResourceTypes.Container or KnownResourceTypes.Executable) + foreach (var command in snapshot.Commands) { - if (snapshot.State is "Exited" or "Finished" or "FailedToStart") - { - resource.Commands.Add(new ResourceCommand - { - CommandType = "Start", - ConfirmationMessage = "ConfirmationMessage!", - DisplayName = "Start", - DisplayDescription = "Start resource", - IsHighlighted = true, - IconName = "Play" - }); - } - else - { - resource.Commands.Add(new ResourceCommand - { - CommandType = "Stop", - ConfirmationMessage = "ConfirmationMessage!", - DisplayName = "Stop", - DisplayDescription = "Stop resource", - IsHighlighted = true, - IconName = "Stop" - }); - } - - resource.Commands.Add(new ResourceCommand - { - CommandType = "Restart", - ConfirmationMessage = "ConfirmationMessage!", - DisplayName = "Restart", - DisplayDescription = "Restart resource", - IsHighlighted = false, - IconName = "ArrowCounterclockwise" - }); + resource.Commands.Add(new ResourceCommand { CommandType = command.Type, DisplayName = command.DisplayName, IconName = command.IconName, IsHighlighted = command.IsHighlighted, State = MapCommandState(command.State) }); } - */ return resource; } + + private static ResourceCommandState MapCommandState(Hosting.ApplicationModel.ResourceCommandState state) + { + return state switch + { + Hosting.ApplicationModel.ResourceCommandState.Enabled => ResourceCommandState.Enabled, + Hosting.ApplicationModel.ResourceCommandState.Disabled => ResourceCommandState.Disabled, + Hosting.ApplicationModel.ResourceCommandState.Hidden => ResourceCommandState.Hidden, + _ => throw new InvalidOperationException("Unexpected state: " + state) + }; + } + } diff --git a/src/Aspire.Hosting/Dashboard/proto/resource_service.proto b/src/Aspire.Hosting/Dashboard/proto/resource_service.proto index e876fbb478..6b5706dd4d 100644 --- a/src/Aspire.Hosting/Dashboard/proto/resource_service.proto +++ b/src/Aspire.Hosting/Dashboard/proto/resource_service.proto @@ -46,6 +46,9 @@ message ResourceCommand { // Optional description of the command, to be shown in the UI. // Could be used as a tooltip. May be localized. optional string display_description = 7; + // The state of the command. Controls whether the command is enabled, disabled, + // or hidden in the UI. + ResourceCommandState state = 8; } // Represents a request to execute a command. @@ -66,6 +69,12 @@ message ResourceCommandRequest { optional google.protobuf.Value parameter = 4; } +enum ResourceCommandState { + ENABLED = 0; + DISABLED = 1; + HIDDEN = 2; +} + enum ResourceCommandResponseKind { UNDEFINED = 0; SUCCEEDED = 1; diff --git a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs index 2d2cb3394e..26ed8d05e0 100644 --- a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs +++ b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs @@ -16,7 +16,10 @@ using Aspire.Hosting.Eventing; using Aspire.Hosting.Lifecycle; using Aspire.Hosting.Utils; +using Json.Patch; using k8s; +using k8s.Autorest; +using k8s.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -26,6 +29,7 @@ namespace Aspire.Hosting.Dcp; +[DebuggerDisplay("ModelResource = {ModelResource}, DcpResource = {DcpResource}")] internal class AppResource { public IResource ModelResource { get; } @@ -1074,86 +1078,90 @@ private void PrepareProjectExecutables() EnsureReplicaInstancesAnnotation(project); - int replicas = project.GetReplicaCount(); + var replicas = project.GetReplicaCount(); - var ers = ExecutableReplicaSet.Create(GetObjectNameForResource(project), replicas, "dotnet"); - var exeSpec = ers.Spec.Template.Spec; - exeSpec.WorkingDirectory = Path.GetDirectoryName(projectMetadata.ProjectPath); + for (var i = 0; i < replicas; i++) + { + var nameSuffix = GetRandomNameSuffix(); + var exeName = GetObjectNameForResource(project, nameSuffix); - IAnnotationHolder annotationHolder = ers.Spec.Template; - annotationHolder.Annotate(CustomResource.OtelServiceNameAnnotation, (replicas > 1) ? ers.Metadata.Name : project.Name); - // The OTEL service instance ID annotation will be generated and applied automatically by DCP. - annotationHolder.Annotate(CustomResource.ResourceNameAnnotation, project.Name); + var exeSpec = Executable.Create(exeName, "dotnet"); + exeSpec.Spec.WorkingDirectory = Path.GetDirectoryName(projectMetadata.ProjectPath); - SetInitialResourceState(project, annotationHolder); + exeSpec.Annotate(CustomResource.OtelServiceNameAnnotation, project.Name); + exeSpec.Annotate(CustomResource.OtelServiceInstanceIdAnnotation, nameSuffix); + exeSpec.Annotate(CustomResource.ResourceNameAnnotation, project.Name); - var projectLaunchConfiguration = new ProjectLaunchConfiguration(); - projectLaunchConfiguration.ProjectPath = projectMetadata.ProjectPath; + SetInitialResourceState(project, exeSpec); - if (!string.IsNullOrEmpty(configuration[DebugSessionPortVar])) - { - exeSpec.ExecutionType = ExecutionType.IDE; + var projectLaunchConfiguration = new ProjectLaunchConfiguration(); + projectLaunchConfiguration.ProjectPath = projectMetadata.ProjectPath; - projectLaunchConfiguration.DisableLaunchProfile = project.TryGetLastAnnotation(out _); - if (!projectLaunchConfiguration.DisableLaunchProfile && project.TryGetLastAnnotation(out var lpa)) + if (!string.IsNullOrEmpty(configuration[DebugSessionPortVar])) { - projectLaunchConfiguration.LaunchProfile = lpa.LaunchProfileName; + exeSpec.Spec.ExecutionType = ExecutionType.IDE; + + projectLaunchConfiguration.DisableLaunchProfile = project.TryGetLastAnnotation(out _); + if (!projectLaunchConfiguration.DisableLaunchProfile && project.TryGetLastAnnotation(out var lpa)) + { + projectLaunchConfiguration.LaunchProfile = lpa.LaunchProfileName; + } } - } - else - { - exeSpec.ExecutionType = ExecutionType.Process; - if (configuration.GetBool("DOTNET_WATCH") is not true) + else { - exeSpec.Args = [ - "run", + exeSpec.Spec.ExecutionType = ExecutionType.Process; + if (configuration.GetBool("DOTNET_WATCH") is not true) + { + exeSpec.Spec.Args = [ + "run", "--no-build", "--project", projectMetadata.ProjectPath, ]; - } - else - { - exeSpec.Args = [ - "watch", + } + else + { + exeSpec.Spec.Args = [ + "watch", "--non-interactive", "--no-hot-reload", "--project", projectMetadata.ProjectPath - ]; - } + ]; + } - if (!string.IsNullOrEmpty(_distributedApplicationOptions.Configuration)) - { - exeSpec.Args.AddRange(new[] { "-c", _distributedApplicationOptions.Configuration }); - } + if (!string.IsNullOrEmpty(_distributedApplicationOptions.Configuration)) + { + exeSpec.Spec.Args.AddRange(new[] { "-c", _distributedApplicationOptions.Configuration }); + } - // We pretty much always want to suppress the normal launch profile handling - // because the settings from the profile will override the ambient environment settings, which is not what we want - // (the ambient environment settings for service processes come from the application model - // and should be HIGHER priority than the launch profile settings). - // This means we need to apply the launch profile settings manually--the invocation parameters here, - // and the environment variables/application URLs inside CreateExecutableAsync(). - exeSpec.Args.Add("--no-launch-profile"); + // We pretty much always want to suppress the normal launch profile handling + // because the settings from the profile will override the ambient environment settings, which is not what we want + // (the ambient environment settings for service processes come from the application model + // and should be HIGHER priority than the launch profile settings). + // This means we need to apply the launch profile settings manually--the invocation parameters here, + // and the environment variables/application URLs inside CreateExecutableAsync(). + exeSpec.Spec.Args.Add("--no-launch-profile"); - var launchProfile = project.GetEffectiveLaunchProfile()?.LaunchProfile; - if (launchProfile is not null && !string.IsNullOrWhiteSpace(launchProfile.CommandLineArgs)) - { - var cmdArgs = CommandLineArgsParser.Parse(launchProfile.CommandLineArgs); - if (cmdArgs.Count > 0) + var launchProfile = project.GetEffectiveLaunchProfile()?.LaunchProfile; + if (launchProfile is not null && !string.IsNullOrWhiteSpace(launchProfile.CommandLineArgs)) { - exeSpec.Args.Add("--"); - exeSpec.Args.AddRange(cmdArgs); + var cmdArgs = CommandLineArgsParser.Parse(launchProfile.CommandLineArgs); + if (cmdArgs.Count > 0) + { + exeSpec.Spec.Args.Add("--"); + exeSpec.Spec.Args.AddRange(cmdArgs); + } } } - } - // We want this annotation even if we are not using IDE execution; see ToSnapshot() for details. - annotationHolder.AnnotateAsObjectList(Executable.LaunchConfigurationsAnnotation, projectLaunchConfiguration); + // We want this annotation even if we are not using IDE execution; see ToSnapshot() for details. + exeSpec.AnnotateAsObjectList(Executable.LaunchConfigurationsAnnotation, projectLaunchConfiguration); - var exeAppResource = new AppResource(project, ers); - AddServicesProducedInfo(project, annotationHolder, exeAppResource); - _appResources.Add(exeAppResource); + var exeAppResource = new AppResource(project, exeSpec); + AddServicesProducedInfo(project, exeSpec, exeAppResource); + _appResources.Add(exeAppResource); + } } } @@ -1920,9 +1928,9 @@ public async Task DeleteResourcesAsync(CancellationToken cancellationToken = def } } - private async Task DeleteResourcesAsync(string resourceType, CancellationToken cancellationToken) where RT : CustomResource + private async Task DeleteResourcesAsync(string resourceType, CancellationToken cancellationToken) where TResource : CustomResource { - var resourcesToDelete = _appResources.Select(r => r.DcpResource).OfType(); + var resourcesToDelete = _appResources.Select(r => r.DcpResource).OfType(); if (!resourcesToDelete.Any()) { return; @@ -1932,7 +1940,7 @@ private async Task DeleteResourcesAsync(string resourceType, CancellationTok { try { - await kubernetesService.DeleteAsync(res.Metadata.Name, res.Metadata.NamespaceProperty, cancellationToken).ConfigureAwait(false); + await kubernetesService.DeleteAsync(res.Metadata.Name, res.Metadata.NamespaceProperty, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -1952,4 +1960,137 @@ private string ReplaceLocalhostWithContainerHost(string value) .Replace("127.0.0.1", hostName) .Replace("[::1]", hostName); } + + /// + /// Create a patch update using the specified resource. + /// A copy is taken of the resource to avoid permanently changing it. + /// + internal static V1Patch CreatePatch(T obj, Action change) where T : CustomResource + { + // This method isn't very efficient. + // If mass or frequent patches are required then we may want to create patches manually. + var current = JsonSerializer.SerializeToNode(obj); + + var copy = JsonSerializer.Deserialize(current)!; + change(copy); + + var changed = JsonSerializer.SerializeToNode(copy); + + var jsonPatch = current.CreatePatch(changed); + return new V1Patch(jsonPatch, V1Patch.PatchType.JsonPatch); + } + + internal async Task StopResourceAsync(string resourceName, CancellationToken cancellationToken) + { + var matchingResource = GetMatchingResource(resourceName); + + V1Patch patch; + switch (matchingResource.DcpResource) + { + case Container c: + patch = CreatePatch(c, obj => obj.Spec.Stop = true); + await kubernetesService.PatchAsync(c, patch, cancellationToken).ConfigureAwait(false); + break; + case Executable e: + patch = CreatePatch(e, obj => obj.Spec.Stop = true); + await kubernetesService.PatchAsync(e, patch, cancellationToken).ConfigureAwait(false); + break; + case ExecutableReplicaSet rs: + patch = CreatePatch(rs, obj => obj.Spec.Replicas = 0); + await kubernetesService.PatchAsync(rs, patch, cancellationToken).ConfigureAwait(false); + break; + default: + throw new InvalidOperationException($"Unexpected resource type: {matchingResource.DcpResource.GetType().FullName}"); + } + } + + private AppResource GetMatchingResource(string resourceName) + { + var matchingResource = _appResources + .Where(r => r.DcpResource is not Service) + .SingleOrDefault(r => string.Equals(r.DcpResource.Metadata.Name, resourceName, StringComparisons.ResourceName)); + if (matchingResource == null) + { + throw new InvalidOperationException($"Resource '{resourceName}' not found."); + } + + return matchingResource; + } + + internal async Task StartResourceAsync(string resourceName, CancellationToken cancellationToken) + { + var matchingResource = GetMatchingResource(resourceName); + + switch (matchingResource.DcpResource) + { + case Container c: + await StartExecutableOrContainerAsync(c).ConfigureAwait(false); + break; + case Executable e: + await StartExecutableOrContainerAsync(e).ConfigureAwait(false); + break; + case ExecutableReplicaSet rs: + var replicas = matchingResource.ModelResource.GetReplicaCount(); + var patch = CreatePatch(rs, obj => obj.Spec.Replicas = replicas); + + await kubernetesService.PatchAsync(rs, patch, cancellationToken).ConfigureAwait(false); + break; + default: + throw new InvalidOperationException($"Unexpected resource type: {matchingResource.DcpResource.GetType().FullName}"); + } + + async Task StartExecutableOrContainerAsync(T resource) where T : CustomResource + { + var resourceName = resource.Metadata.Name; + _logger.LogDebug("Starting {ResouceType} '{ResourceName}'.", typeof(T).Name, resourceName); + + var resourceNotFound = false; + try + { + await kubernetesService.DeleteAsync(resourceName, cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + // No-op if the resource wasn't found. + // This could happen in a race condition, e.g. double clicking start button. + resourceNotFound = true; + } + + // Ensure resource is deleted. DeleteAsync returns before the resource is completely deleted so we must poll + // to discover when it is safe to recreate the resource. This is required because the resources share the same name. + if (!resourceNotFound) + { + var ensureDeleteRetryStrategy = new RetryStrategyOptions() + { + BackoffType = DelayBackoffType.Linear, + MaxDelay = TimeSpan.FromSeconds(0.5), + UseJitter = true, + MaxRetryAttempts = 5, + ShouldHandle = new PredicateBuilder().Handle(), + OnRetry = (retry) => + { + _logger.LogDebug("Retrying check for deleted resource '{ResourceName}'. Attempt: {Attempt}", resourceName, retry.AttemptNumber); + return ValueTask.CompletedTask; + } + }; + + var execution = new ResiliencePipelineBuilder().AddRetry(ensureDeleteRetryStrategy).Build(); + + await execution.ExecuteAsync(async (attemptCancellationToken) => + { + try + { + await kubernetesService.GetAsync(resource.Metadata.Name, cancellationToken: attemptCancellationToken).ConfigureAwait(false); + throw new DistributedApplicationException($"Failed to delete '{resource.Metadata.Name}' successfully before restart."); + } + catch (HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + // Success. + } + }, cancellationToken).ConfigureAwait(false); + } + + await kubernetesService.CreateAsync(resource, cancellationToken).ConfigureAwait(false); + } + } } diff --git a/src/Aspire.Hosting/Dcp/KubernetesService.cs b/src/Aspire.Hosting/Dcp/KubernetesService.cs index 902234b695..44797d8069 100644 --- a/src/Aspire.Hosting/Dcp/KubernetesService.cs +++ b/src/Aspire.Hosting/Dcp/KubernetesService.cs @@ -24,6 +24,7 @@ internal enum DcpApiOperationType Watch = 4, GetLogSubresource = 5, Get = 6, + Patch = 7 } internal interface IKubernetesService @@ -32,6 +33,8 @@ Task GetAsync(string name, string? namespaceParameter = null, Cancellation where T: CustomResource; Task CreateAsync(T obj, CancellationToken cancellationToken = default) where T : CustomResource; + Task PatchAsync(T obj, V1Patch patch, CancellationToken cancellationToken = default) + where T : CustomResource; Task> ListAsync(string? namespaceParameter = null, CancellationToken cancellationToken = default) where T : CustomResource; Task DeleteAsync(string name, string? namespaceParameter = null, CancellationToken cancellationToken = default) @@ -122,6 +125,40 @@ public Task CreateAsync(T obj, CancellationToken cancellationToken = defau cancellationToken); } + public Task PatchAsync(T obj, V1Patch patch, CancellationToken cancellationToken = default) + where T : CustomResource + { + ObjectDisposedException.ThrowIf(_disposed, this); + var resourceType = GetResourceFor(); + var namespaceParameter = obj.Namespace(); + + return ExecuteWithRetry( + DcpApiOperationType.Patch, + resourceType, + async (kubernetes) => + { + var response = string.IsNullOrEmpty(namespaceParameter) + ? await kubernetes.CustomObjects.PatchClusterCustomObjectWithHttpMessagesAsync( + patch, + GroupVersion.Group, + GroupVersion.Version, + resourceType, + obj.Metadata.Name, + cancellationToken: cancellationToken).ConfigureAwait(false) + : await kubernetes.CustomObjects.PatchNamespacedCustomObjectWithHttpMessagesAsync( + patch, + GroupVersion.Group, + GroupVersion.Version, + namespaceParameter, + resourceType, + obj.Metadata.Name, + cancellationToken: cancellationToken).ConfigureAwait(false); + + return KubernetesJson.Deserialize(response.Body.ToString()); + }, + cancellationToken); + } + public Task> ListAsync(string? namespaceParameter = null, CancellationToken cancellationToken = default) where T : CustomResource { diff --git a/src/Aspire.Hosting/Dcp/Model/Container.cs b/src/Aspire.Hosting/Dcp/Model/Container.cs index f54a08e93e..cbc79e4c13 100644 --- a/src/Aspire.Hosting/Dcp/Model/Container.cs +++ b/src/Aspire.Hosting/Dcp/Model/Container.cs @@ -64,6 +64,10 @@ internal sealed class ContainerSpec [JsonPropertyName("networks")] public List? Networks { get; set; } + // Should this resource be stopped? + [JsonPropertyName("stop")] + public bool? Stop { get; set; } + /// /// Optional lifecycle key for the resource (used to identify changes to persistent resources requiring a restart). /// If unset, DCP will calculate a default lifecycle key based on a hash of various resource spec properties. diff --git a/src/Aspire.Hosting/Dcp/Model/Executable.cs b/src/Aspire.Hosting/Dcp/Model/Executable.cs index 2427af9b2c..91eb73f363 100644 --- a/src/Aspire.Hosting/Dcp/Model/Executable.cs +++ b/src/Aspire.Hosting/Dcp/Model/Executable.cs @@ -50,6 +50,10 @@ internal sealed class ExecutableSpec /// [JsonPropertyName("healthProbes")] public List? HealthProbes { get; set; } + + // Should this resource be stopped? + [JsonPropertyName("stop")] + public bool? Stop { get; set; } } internal static class ExecutionType diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 0445ddd906..2028b0ae11 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -234,6 +234,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) ); } + _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddOptions().ValidateOnStart().PostConfigure(MapTransportOptionsFromCustomKeys); _innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, TransportOptionsValidator>()); _innerBuilder.Services.AddSingleton(); diff --git a/src/Aspire.Hosting/ExecutableResourceBuilderExtensions.cs b/src/Aspire.Hosting/ExecutableResourceBuilderExtensions.cs index ac94b5da83..9754fc0f7f 100644 --- a/src/Aspire.Hosting/ExecutableResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ExecutableResourceBuilderExtensions.cs @@ -47,7 +47,8 @@ public static IResourceBuilder AddExecutable(this IDistribut { context.Args.AddRange(args); } - }); + }) + .WithLifeCycleCommands(); } /// diff --git a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs index fa58fdb8bb..d582c55815 100644 --- a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs @@ -282,6 +282,7 @@ private static IResourceBuilder WithProjectDefaults(this IResou builder.WithOtlpExporter(); builder.ConfigureConsoleLogs(); + builder.WithLifeCycleCommands(); var projectResource = builder.Resource; diff --git a/src/Aspire.Hosting/PublicAPI.Shipped.txt b/src/Aspire.Hosting/PublicAPI.Shipped.txt index fd7387bf17..dcd312f1fa 100644 --- a/src/Aspire.Hosting/PublicAPI.Shipped.txt +++ b/src/Aspire.Hosting/PublicAPI.Shipped.txt @@ -298,7 +298,6 @@ Aspire.Hosting.ApplicationModel.ResourceLoggerService.WatchAsync(string! resourc Aspire.Hosting.ApplicationModel.ResourceNotificationService Aspire.Hosting.ApplicationModel.ResourceNotificationService.PublishUpdateAsync(Aspire.Hosting.ApplicationModel.IResource! resource, string! resourceId, System.Func! stateFactory) -> System.Threading.Tasks.Task! Aspire.Hosting.ApplicationModel.ResourceNotificationService.PublishUpdateAsync(Aspire.Hosting.ApplicationModel.IResource! resource, System.Func! stateFactory) -> System.Threading.Tasks.Task! -Aspire.Hosting.ApplicationModel.ResourceNotificationService.ResourceNotificationService(Microsoft.Extensions.Logging.ILogger! logger) -> void Aspire.Hosting.ApplicationModel.ResourceNotificationService.WatchAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! Aspire.Hosting.ApplicationModel.ResourcePropertySnapshot Aspire.Hosting.ApplicationModel.ResourcePropertySnapshot.Name.get -> string! diff --git a/src/Aspire.Hosting/PublicAPI.Unshipped.txt b/src/Aspire.Hosting/PublicAPI.Unshipped.txt index 005334fa77..c659eeaf64 100644 --- a/src/Aspire.Hosting/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting/PublicAPI.Unshipped.txt @@ -15,6 +15,7 @@ Aspire.Hosting.ApplicationModel.BeforeStartEvent Aspire.Hosting.ApplicationModel.BeforeStartEvent.BeforeStartEvent(System.IServiceProvider! services, Aspire.Hosting.ApplicationModel.DistributedApplicationModel! model) -> void Aspire.Hosting.ApplicationModel.BeforeStartEvent.Model.get -> Aspire.Hosting.ApplicationModel.DistributedApplicationModel! Aspire.Hosting.ApplicationModel.BeforeStartEvent.Services.get -> System.IServiceProvider! +Aspire.Hosting.ApplicationModel.CommandResults Aspire.Hosting.ApplicationModel.ConnectionStringAvailableEvent Aspire.Hosting.ApplicationModel.ConnectionStringAvailableEvent.ConnectionStringAvailableEvent(Aspire.Hosting.ApplicationModel.IResource! resource, System.IServiceProvider! services) -> void Aspire.Hosting.ApplicationModel.ConnectionStringAvailableEvent.Resource.get -> Aspire.Hosting.ApplicationModel.IResource! @@ -32,17 +33,64 @@ Aspire.Hosting.ApplicationModel.ContainerNameAnnotation Aspire.Hosting.ApplicationModel.ContainerNameAnnotation.ContainerNameAnnotation() -> void Aspire.Hosting.ApplicationModel.ContainerNameAnnotation.Name.get -> string! Aspire.Hosting.ApplicationModel.ContainerNameAnnotation.Name.set -> void +Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.Commands.get -> System.Collections.Immutable.ImmutableArray +Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.Commands.init -> void Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.HealthStatus.get -> Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.HealthStatus.init -> void Aspire.Hosting.ApplicationModel.EndpointAnnotation.TargetHost.get -> string! Aspire.Hosting.ApplicationModel.EndpointAnnotation.TargetHost.set -> void Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.Volumes.get -> System.Collections.Immutable.ImmutableArray Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.Volumes.init -> void +Aspire.Hosting.ApplicationModel.ExecuteCommandContext +Aspire.Hosting.ApplicationModel.ExecuteCommandContext.CancellationToken.get -> System.Threading.CancellationToken +Aspire.Hosting.ApplicationModel.ExecuteCommandContext.CancellationToken.init -> void +Aspire.Hosting.ApplicationModel.ExecuteCommandContext.ExecuteCommandContext() -> void +Aspire.Hosting.ApplicationModel.ExecuteCommandContext.ResourceName.get -> string! +Aspire.Hosting.ApplicationModel.ExecuteCommandContext.ResourceName.init -> void +Aspire.Hosting.ApplicationModel.ExecuteCommandContext.ServiceProvider.get -> System.IServiceProvider! +Aspire.Hosting.ApplicationModel.ExecuteCommandContext.ServiceProvider.init -> void +Aspire.Hosting.ApplicationModel.ExecuteCommandResult +Aspire.Hosting.ApplicationModel.ExecuteCommandResult.ErrorMessage.get -> string? +Aspire.Hosting.ApplicationModel.ExecuteCommandResult.ErrorMessage.init -> void +Aspire.Hosting.ApplicationModel.ExecuteCommandResult.ExecuteCommandResult() -> void +Aspire.Hosting.ApplicationModel.ExecuteCommandResult.Success.get -> bool +Aspire.Hosting.ApplicationModel.ExecuteCommandResult.Success.init -> void Aspire.Hosting.ApplicationModel.HealthCheckAnnotation Aspire.Hosting.ApplicationModel.HealthCheckAnnotation.HealthCheckAnnotation(string! key) -> void Aspire.Hosting.ApplicationModel.HealthCheckAnnotation.Key.get -> string! +Aspire.Hosting.ApplicationModel.ResourceCommandAnnotation +Aspire.Hosting.ApplicationModel.ResourceCommandAnnotation.DisplayName.get -> string! +Aspire.Hosting.ApplicationModel.ResourceCommandAnnotation.ExecuteCommand.get -> System.Func!>! +Aspire.Hosting.ApplicationModel.ResourceCommandAnnotation.IconName.get -> string? +Aspire.Hosting.ApplicationModel.ResourceCommandAnnotation.IsHighlighted.get -> bool +Aspire.Hosting.ApplicationModel.ResourceCommandAnnotation.ResourceCommandAnnotation(string! type, string! displayName, System.Func! updateState, System.Func!>! executeCommand, string? iconName, bool isHighlighted) -> void +Aspire.Hosting.ApplicationModel.ResourceCommandAnnotation.Type.get -> string! +Aspire.Hosting.ApplicationModel.ResourceCommandAnnotation.UpdateState.get -> System.Func! +Aspire.Hosting.ApplicationModel.ResourceCommandSnapshot +Aspire.Hosting.ApplicationModel.ResourceCommandSnapshot.DisplayName.get -> string! +Aspire.Hosting.ApplicationModel.ResourceCommandSnapshot.DisplayName.init -> void +Aspire.Hosting.ApplicationModel.ResourceCommandSnapshot.IconName.get -> string? +Aspire.Hosting.ApplicationModel.ResourceCommandSnapshot.IconName.init -> void +Aspire.Hosting.ApplicationModel.ResourceCommandSnapshot.IsHighlighted.get -> bool +Aspire.Hosting.ApplicationModel.ResourceCommandSnapshot.IsHighlighted.init -> void +Aspire.Hosting.ApplicationModel.ResourceCommandSnapshot.ResourceCommandSnapshot(string! Type, Aspire.Hosting.ApplicationModel.ResourceCommandState State, string! DisplayName, string? IconName, bool IsHighlighted) -> void +Aspire.Hosting.ApplicationModel.ResourceCommandSnapshot.State.get -> Aspire.Hosting.ApplicationModel.ResourceCommandState +Aspire.Hosting.ApplicationModel.ResourceCommandSnapshot.State.init -> void +Aspire.Hosting.ApplicationModel.ResourceCommandSnapshot.Type.get -> string! +Aspire.Hosting.ApplicationModel.ResourceCommandSnapshot.Type.init -> void +Aspire.Hosting.ApplicationModel.ResourceCommandState +Aspire.Hosting.ApplicationModel.ResourceCommandState.Disabled = 1 -> Aspire.Hosting.ApplicationModel.ResourceCommandState +Aspire.Hosting.ApplicationModel.ResourceCommandState.Enabled = 0 -> Aspire.Hosting.ApplicationModel.ResourceCommandState +Aspire.Hosting.ApplicationModel.ResourceCommandState.Hidden = 2 -> Aspire.Hosting.ApplicationModel.ResourceCommandState Aspire.Hosting.ApplicationModel.ResourceNotificationService.ResourceNotificationService(Microsoft.Extensions.Logging.ILogger! logger, Microsoft.Extensions.Hosting.IHostApplicationLifetime! hostApplicationLifetime) -> void +Aspire.Hosting.ApplicationModel.ResourceNotificationService.ResourceNotificationService(Microsoft.Extensions.Logging.ILogger! logger, Microsoft.Extensions.Hosting.IHostApplicationLifetime! hostApplicationLifetime, System.IServiceProvider! serviceProvider) -> void Aspire.Hosting.ApplicationModel.ResourceNotificationService.WaitForResourceAsync(string! resourceName, System.Func! predicate, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Aspire.Hosting.ApplicationModel.UpdateCommandStateContext +Aspire.Hosting.ApplicationModel.UpdateCommandStateContext.ResourceSnapshot.get -> Aspire.Hosting.ApplicationModel.CustomResourceSnapshot! +Aspire.Hosting.ApplicationModel.UpdateCommandStateContext.ResourceSnapshot.init -> void +Aspire.Hosting.ApplicationModel.UpdateCommandStateContext.ServiceProvider.get -> System.IServiceProvider! +Aspire.Hosting.ApplicationModel.UpdateCommandStateContext.ServiceProvider.init -> void +Aspire.Hosting.ApplicationModel.UpdateCommandStateContext.UpdateCommandStateContext() -> void Aspire.Hosting.ApplicationModel.VolumeSnapshot Aspire.Hosting.ApplicationModel.VolumeSnapshot.IsReadOnly.get -> bool Aspire.Hosting.ApplicationModel.VolumeSnapshot.IsReadOnly.init -> void @@ -76,6 +124,7 @@ Aspire.Hosting.Eventing.IDistributedApplicationEventing.Unsubscribe(Aspire.Hosti Aspire.Hosting.Eventing.IDistributedApplicationResourceEvent Aspire.Hosting.Eventing.IDistributedApplicationResourceEvent.Resource.get -> Aspire.Hosting.ApplicationModel.IResource! Aspire.Hosting.IDistributedApplicationBuilder.Eventing.get -> Aspire.Hosting.Eventing.IDistributedApplicationEventing! +static Aspire.Hosting.ApplicationModel.CommandResults.Success() -> Aspire.Hosting.ApplicationModel.ExecuteCommandResult! static Aspire.Hosting.ApplicationModel.ResourceExtensions.GetEnvironmentVariableValuesAsync(this Aspire.Hosting.ApplicationModel.IResourceWithEnvironment! resource, Aspire.Hosting.DistributedApplicationOperation applicationOperation = Aspire.Hosting.DistributedApplicationOperation.Run) -> System.Threading.Tasks.ValueTask!> Aspire.Hosting.ApplicationModel.ResourceNotificationService.WaitForResourceAsync(string! resourceName, string? targetState = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Aspire.Hosting.ApplicationModel.ResourceNotificationService.WaitForResourceAsync(string! resourceName, System.Collections.Generic.IEnumerable! targetStates, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! @@ -95,6 +144,7 @@ Aspire.Hosting.DistributedApplicationExecutionContextOptions.ServiceProvider.get Aspire.Hosting.DistributedApplicationExecutionContextOptions.ServiceProvider.set -> void static Aspire.Hosting.ResourceBuilderExtensions.WaitFor(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder! dependency) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ResourceBuilderExtensions.WaitForCompletion(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder! dependency, int exitCode = 0) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.ResourceBuilderExtensions.WithCommand(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! type, string! displayName, System.Func!>! executeCommand, System.Func? updateState = null, string? iconName = null, bool isHighlighted = false) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ResourceBuilderExtensions.WithHealthCheck(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! key) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.Exited -> string! static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.FailedToStart -> string! diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 72804b81ad..7563d24b2d 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -770,4 +770,46 @@ public static IResourceBuilder WithHealthCheck(this IResourceBuilder bu return builder; } + + /// + /// Adds a to the resource annotations to add a resource command. + /// + /// The type of the resource. + /// The resource builder. + /// The type of command. The type uniquely identifies the command. + /// The display name visible in UI. + /// + /// A callback that is executed when the command is executed. The callback is run inside the .NET Aspire host. + /// The callback result is used to indicate success or failure in the UI. + /// + /// + /// A callback that is used to update the command state. The callback is executed when the command's resource snapshot is updated. + /// If a callback isn't specified, the command is always enabled. + /// + /// The icon name for the command. The name should be a valid FluentUI icon name. https://aka.ms/fluentui-system-icons + /// A flag indicating whether the command is highlighted in the UI. + /// The resource builder. + /// + /// The WithCommand method is used to add commands to the resource. Commands are displayed in the dashboard + /// and can be executed by a user using the dashboard UI. + /// When a command is executed, the callback is called and is run inside the .NET Aspire host. + /// + public static IResourceBuilder WithCommand( + this IResourceBuilder builder, + string type, + string displayName, + Func> executeCommand, + Func? updateState = null, + string? iconName = null, + bool isHighlighted = false) where T : IResource + { + // Replace existing annotation with the same name. + var existingAnnotation = builder.Resource.Annotations.OfType().SingleOrDefault(a => a.Type == type); + if (existingAnnotation != null) + { + builder.Resource.Annotations.Remove(existingAnnotation); + } + + return builder.WithAnnotation(new ResourceCommandAnnotation(type, displayName, updateState ?? (c => ResourceCommandState.Enabled), executeCommand, iconName, isHighlighted)); + } } diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs index f5a8bf968f..716b0da8de 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs @@ -89,18 +89,18 @@ public void ResourceName_MultiRender_SubscribeConsoleLogsOnce() } [Fact] - public void ResourceName_ViaUrlAndResourceLoaded_LogViewerUpdated() + public async Task ResourceName_ViaUrlAndResourceLoaded_LogViewerUpdated() { // Arrange var testResource = CreateResourceViewModel("test-resource", KnownResourceState.Running); - var subscribedResourceNames = new List(); + var subscribedResourceNameTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var consoleLogsChannel = Channel.CreateUnbounded>(); var resourceChannel = Channel.CreateUnbounded>(); var dashboardClient = new TestDashboardClient( isEnabled: true, consoleLogsChannelProvider: name => { - subscribedResourceNames.Add(name); + subscribedResourceNameTcs.TrySetResult(name); return consoleLogsChannel; }, resourceChannelProvider: () => resourceChannel, @@ -128,7 +128,8 @@ public void ResourceName_ViaUrlAndResourceLoaded_LogViewerUpdated() cut.WaitForState(() => instance.PageViewModel.SelectedResource == testResource); cut.WaitForState(() => instance.PageViewModel.Status == loc[nameof(Resources.ConsoleLogs.ConsoleLogsWatchingLogs)]); - cut.WaitForAssertion(() => Assert.Single(subscribedResourceNames)); + var subscribedResource = await subscribedResourceNameTcs.Task; + Assert.Equal("test-resource", subscribedResource); logger.LogInformation("Log results are added to log viewer."); consoleLogsChannel.Writer.TryWrite([new ResourceLogLine(1, "Hello world", IsErrorMessage: false)]); diff --git a/tests/Aspire.Hosting.Containers.Tests/ContainerResourceTests.cs b/tests/Aspire.Hosting.Containers.Tests/ContainerResourceTests.cs index 64ad0aa990..57b1a3a5b8 100644 --- a/tests/Aspire.Hosting.Containers.Tests/ContainerResourceTests.cs +++ b/tests/Aspire.Hosting.Containers.Tests/ContainerResourceTests.cs @@ -24,7 +24,7 @@ public void AddContainerAddsAnnotationMetadata() var containerResource = Assert.Single(containerResources); Assert.Equal("container", containerResource.Name); - var containerAnnotation = Assert.IsType(Assert.Single(containerResource.Annotations)); + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); Assert.Equal("latest", containerAnnotation.Tag); Assert.Equal("none", containerAnnotation.Image); Assert.Null(containerAnnotation.Registry); @@ -43,7 +43,7 @@ public void AddContainerAddsAnnotationMetadataWithTag() var containerResource = Assert.Single(containerResources); Assert.Equal("container", containerResource.Name); - var containerAnnotation = Assert.IsType(Assert.Single(containerResource.Annotations)); + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); Assert.Equal("nightly", containerAnnotation.Tag); Assert.Equal("none", containerAnnotation.Image); Assert.Null(containerAnnotation.Registry); diff --git a/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs b/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs index c29d93d0d5..09e788fbd1 100644 --- a/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs @@ -30,7 +30,7 @@ public async Task BackgroundServiceIsRegisteredInServiceProvider() public async Task ExecuteDoesNotThrowOperationCanceledWhenAppStoppingTokenSignaled() { var hostApplicationLifetime = new TestHostApplicationLifetime(); - var resourceNotificationService = new ResourceNotificationService(NullLogger.Instance, hostApplicationLifetime); + var resourceNotificationService = CreateResourceNotificationService(hostApplicationLifetime); var resourceLoggerService = new ResourceLoggerService(); var hostEnvironment = new HostingEnvironment(); var loggerFactory = new NullLoggerFactory(); @@ -51,7 +51,7 @@ public async Task ExecuteDoesNotThrowOperationCanceledWhenAppStoppingTokenSignal public async Task ResourceLogsAreForwardedToHostLogging() { var hostApplicationLifetime = new TestHostApplicationLifetime(); - var resourceNotificationService = new ResourceNotificationService(NullLogger.Instance, hostApplicationLifetime); + var resourceNotificationService = CreateResourceNotificationService(hostApplicationLifetime); var resourceLoggerService = ConsoleLoggingTestHelpers.GetResourceLoggerService(); var hostEnvironment = new HostingEnvironment { ApplicationName = "TestApp.AppHost" }; var fakeLoggerProvider = new FakeLoggerProvider(); @@ -124,6 +124,11 @@ public async Task ResourceLogsAreForwardedToHostLogging() log => { Assert.Equal(LogLevel.Error, log.Level); Assert.Equal("6: 2000-12-29T20:59:59.0000000Z Test critical message", log.Message); Assert.Equal("TestApp.AppHost.Resources.myresource", log.Category); }); } + private static ResourceNotificationService CreateResourceNotificationService(TestHostApplicationLifetime hostApplicationLifetime) + { + return new ResourceNotificationService(NullLogger.Instance, hostApplicationLifetime, new ServiceCollection().BuildServiceProvider()); + } + private sealed class CustomResource(string name) : Resource(name) { diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs index 51aa395ee3..5a8519d3c5 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs @@ -6,6 +6,7 @@ using System.Threading.Channels; using Aspire.Hosting.Dashboard; using Aspire.Hosting.Dcp; +using Aspire.Hosting.Tests.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -33,7 +34,7 @@ public async Task WatchDashboardLogs_WrittenToHostLoggerFactory(DateTime? timest testSink.MessageLogged += c => logChannel.Writer.TryWrite(c); var resourceLoggerService = new ResourceLoggerService(); - var resourceNotificationService = new ResourceNotificationService(NullLogger.Instance, new TestHostApplicationLifetime()); + var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); var configuration = new ConfigurationBuilder().Build(); var hook = new DashboardLifecycleHook( configuration, diff --git a/tests/Aspire.Hosting.Tests/Dashboard/ResourcePublisherTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/ResourcePublisherTests.cs index 666c646ad8..b6e5f7778c 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/ResourcePublisherTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/ResourcePublisherTests.cs @@ -9,7 +9,7 @@ namespace Aspire.Hosting.Tests.Dashboard; public class ResourcePublisherTests { - [Fact(Skip = "Passes locally but fails in CI. https://github.com/dotnet/aspire/issues/1410")] + [Fact] public async Task ProducesExpectedSnapshotAndUpdates() { CancellationTokenSource cts = new(); @@ -19,8 +19,8 @@ public async Task ProducesExpectedSnapshotAndUpdates() var b = CreateResourceSnapshot("B"); var c = CreateResourceSnapshot("C"); - await publisher.IntegrateAsync(a, ResourceSnapshotChangeType.Upsert); - await publisher.IntegrateAsync(b, ResourceSnapshotChangeType.Upsert); + await publisher.IntegrateAsync(new TestResource("A"), a, ResourceSnapshotChangeType.Upsert); + await publisher.IntegrateAsync(new TestResource("B"), b, ResourceSnapshotChangeType.Upsert); Assert.Equal(0, publisher.OutgoingSubscriberCount); @@ -32,29 +32,32 @@ public async Task ProducesExpectedSnapshotAndUpdates() Assert.Single(snapshot.Where(s => s.Name == "A")); Assert.Single(snapshot.Where(s => s.Name == "B")); - using AutoResetEvent sync = new(initialState: false); - List> changeBatches = []; + var tcs = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); var task = Task.Run(async () => { await foreach (var change in subscription) { - changeBatches.Add(change); - sync.Set(); + tcs.TrySetResult(change); } }); - await publisher.IntegrateAsync(c, ResourceSnapshotChangeType.Upsert); - - Assert.True(sync.WaitOne(TimeSpan.FromSeconds(1))); + await publisher.IntegrateAsync(new TestResource("C"), c, ResourceSnapshotChangeType.Upsert); - var change = Assert.Single(changeBatches.SelectMany(o => o)); + var change = Assert.Single(await tcs.Task); Assert.Equal(ResourceSnapshotChangeType.Upsert, change.ChangeType); Assert.Equal("C", change.Resource.Name); await cts.CancelAsync(); - await Assert.ThrowsAsync(() => task); + try + { + await task; + } + catch (OperationCanceledException) + { + // Ignore possible cancellation error. + } Assert.Equal(0, publisher.OutgoingSubscriberCount); } @@ -69,8 +72,8 @@ public async Task SupportsMultipleSubscribers() var b = CreateResourceSnapshot("B"); var c = CreateResourceSnapshot("C"); - await publisher.IntegrateAsync(a, ResourceSnapshotChangeType.Upsert); - await publisher.IntegrateAsync(b, ResourceSnapshotChangeType.Upsert); + await publisher.IntegrateAsync(new TestResource("A"), a, ResourceSnapshotChangeType.Upsert); + await publisher.IntegrateAsync(new TestResource("B"), b, ResourceSnapshotChangeType.Upsert); Assert.Equal(0, publisher.OutgoingSubscriberCount); @@ -82,7 +85,7 @@ public async Task SupportsMultipleSubscribers() Assert.Equal(2, snapshot1.Length); Assert.Equal(2, snapshot2.Length); - await publisher.IntegrateAsync(c, ResourceSnapshotChangeType.Upsert); + await publisher.IntegrateAsync(new TestResource("C"), c, ResourceSnapshotChangeType.Upsert); var enumerator1 = subscription1.GetAsyncEnumerator(cts.Token); var enumerator2 = subscription2.GetAsyncEnumerator(cts.Token); @@ -116,9 +119,9 @@ public async Task MergesResourcesInSnapshot() var a2 = CreateResourceSnapshot("A"); var a3 = CreateResourceSnapshot("A"); - await publisher.IntegrateAsync(a1, ResourceSnapshotChangeType.Upsert); - await publisher.IntegrateAsync(a2, ResourceSnapshotChangeType.Upsert); - await publisher.IntegrateAsync(a3, ResourceSnapshotChangeType.Upsert); + await publisher.IntegrateAsync(new TestResource("A"), a1, ResourceSnapshotChangeType.Upsert); + await publisher.IntegrateAsync(new TestResource("A"), a2, ResourceSnapshotChangeType.Upsert); + await publisher.IntegrateAsync(new TestResource("A"), a3, ResourceSnapshotChangeType.Upsert); var (snapshot, _) = publisher.Subscribe(); @@ -136,9 +139,9 @@ public async Task DeletesRemoveFromSnapshot() var a = CreateResourceSnapshot("A"); var b = CreateResourceSnapshot("B"); - await publisher.IntegrateAsync(a, ResourceSnapshotChangeType.Upsert); - await publisher.IntegrateAsync(b, ResourceSnapshotChangeType.Upsert); - await publisher.IntegrateAsync(a, ResourceSnapshotChangeType.Delete); + await publisher.IntegrateAsync(new TestResource("A"), a, ResourceSnapshotChangeType.Upsert); + await publisher.IntegrateAsync(new TestResource("B"), b, ResourceSnapshotChangeType.Upsert); + await publisher.IntegrateAsync(new TestResource("A"), a, ResourceSnapshotChangeType.Delete); var (snapshot, _) = publisher.Subscribe(); @@ -170,7 +173,7 @@ public async Task CancelledSubscriptionIsCleanedUp() }); // Push through an update. - await publisher.IntegrateAsync(CreateResourceSnapshot("A"), ResourceSnapshotChangeType.Upsert); + await publisher.IntegrateAsync(new TestResource("A"), CreateResourceSnapshot("A"), ResourceSnapshotChangeType.Upsert); // Let the subscriber exit. await task; @@ -194,7 +197,12 @@ private static GenericResourceSnapshot CreateResourceSnapshot(string name) Urls = [], Volumes = [], Environment = [], - HealthState = null + HealthState = null, + Commands = [] }; } + + private sealed class TestResource(string name) : Resource(name) + { + } } diff --git a/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs index a5aa698791..ed30fbfd12 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs @@ -13,7 +13,6 @@ using k8s.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Xunit; @@ -349,7 +348,7 @@ public async Task UnsupportedEndpointPortsExecutableNotReplicatedProxyless() [Theory] [InlineData(1, "ServiceA")] - [InlineData(2, "ServiceA-suffix")] + [InlineData(2, "ServiceA")] public async Task EndpointOtelServiceName(int replicaCount, string expectedName) { var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions @@ -367,8 +366,13 @@ public async Task EndpointOtelServiceName(int replicaCount, string expectedName) var appExecutor = CreateAppExecutor(distributedAppModel, app.Services, kubernetesService: kubernetesService, dcpOptions: dcpOptions); await appExecutor.RunApplicationAsync(); - var ers = Assert.Single(kubernetesService.CreatedResources.OfType()); - Assert.Equal(expectedName, ers.Spec?.Template.Annotations?[CustomResource.OtelServiceNameAnnotation]); + var executables = kubernetesService.CreatedResources.OfType().ToList(); + Assert.Equal(replicaCount, executables.Count); + + foreach (var exe in executables) + { + Assert.Equal(expectedName, exe.Metadata.Annotations[CustomResource.OtelServiceNameAnnotation]); + } } [Fact] @@ -602,25 +606,30 @@ public async Task EndpointPortsProjectNoPortNoTargetPort() var appExecutor = CreateAppExecutor(distributedAppModel, app.Services, kubernetesService: kubernetesService); await appExecutor.RunApplicationAsync(); - var ers = Assert.Single(kubernetesService.CreatedResources.OfType()); - Assert.True(ers.Spec.Template.TryGetAnnotationAsObjectList(CustomResource.ServiceProducerAnnotation, out var spAnnList)); - - // Neither Port, nor TargetPort are set - // Clients use proxy, MAY have the proxy port injected. - // Proxy gets autogenerated port. - // Each replica gets a different autogenerated port that MUST be injected via env var/startup param. - var svc = kubernetesService.CreatedResources.OfType().Single(s => s.Name() == "ServiceA-NoPortNoTargetPort"); - Assert.Equal(AddressAllocationModes.Localhost, svc.Spec.AddressAllocationMode); - Assert.True(svc.Status?.EffectivePort >= TestKubernetesService.StartOfAutoPortRange); - Assert.True(spAnnList.Single(ann => ann.ServiceName == "ServiceA-NoPortNoTargetPort").Port is null, - "Expected service producer (target) port to not be set (leave allocation to DCP)"); - var envVarVal = ers.Spec.Template.Spec.Env?.Single(v => v.Name == "NO_PORT_NO_TARGET_PORT").Value; - Assert.False(string.IsNullOrWhiteSpace(envVarVal)); - Assert.Contains("""portForServing "ServiceA-NoPortNoTargetPort" """, envVarVal); + var exes = kubernetesService.CreatedResources.OfType().ToList(); + Assert.Equal(3, exes.Count); - // ASPNETCORE_URLS should not include dontinjectme, as it was excluded using WithEndpointsInEnvironment - var aspnetCoreUrls = ers.Spec.Template.Spec.Env?.Single(v => v.Name == "ASPNETCORE_URLS").Value; - Assert.Equal("http://localhost:{{- portForServing \"ServiceA-http\" -}};http://localhost:{{- portForServing \"ServiceA-hp1\" -}}", aspnetCoreUrls); + foreach (var dcpExe in exes) + { + Assert.True(dcpExe.TryGetAnnotationAsObjectList(CustomResource.ServiceProducerAnnotation, out var spAnnList)); + + // Neither Port, nor TargetPort are set + // Clients use proxy, MAY have the proxy port injected. + // Proxy gets autogenerated port. + // Each replica gets a different autogenerated port that MUST be injected via env var/startup param. + var svc = kubernetesService.CreatedResources.OfType().Single(s => s.Name() == "ServiceA-NoPortNoTargetPort"); + Assert.Equal(AddressAllocationModes.Localhost, svc.Spec.AddressAllocationMode); + Assert.True(svc.Status?.EffectivePort >= TestKubernetesService.StartOfAutoPortRange); + Assert.True(spAnnList.Single(ann => ann.ServiceName == "ServiceA-NoPortNoTargetPort").Port is null, + "Expected service producer (target) port to not be set (leave allocation to DCP)"); + var envVarVal = dcpExe.Spec.Env?.Single(v => v.Name == "NO_PORT_NO_TARGET_PORT").Value; + Assert.False(string.IsNullOrWhiteSpace(envVarVal)); + Assert.Contains("""portForServing "ServiceA-NoPortNoTargetPort" """, envVarVal); + + // ASPNETCORE_URLS should not include dontinjectme, as it was excluded using WithEndpointsInEnvironment + var aspnetCoreUrls = dcpExe.Spec.Env?.Single(v => v.Name == "ASPNETCORE_URLS").Value; + Assert.Equal("http://localhost:{{- portForServing \"ServiceA-http\" -}};http://localhost:{{- portForServing \"ServiceA-hp1\" -}}", aspnetCoreUrls); + } } [Fact] @@ -642,77 +651,25 @@ public async Task EndpointPortsProjectPortSetNoTargetPort() var appExecutor = CreateAppExecutor(distributedAppModel, app.Services, kubernetesService: kubernetesService); await appExecutor.RunApplicationAsync(); - var ers = Assert.Single(kubernetesService.CreatedResources.OfType()); - Assert.True(ers.Spec.Template.TryGetAnnotationAsObjectList(CustomResource.ServiceProducerAnnotation, out var spAnnList)); - - // Port is set, but TargetPort is empty. - // Clients use proxy, MAY have the proxy port injected. - // Proxy uses Port. - // Each replica gets a different autogenerated port that MUST be injected via env var/startup param. - var svc = kubernetesService.CreatedResources.OfType().Single(s => s.Name() == "ServiceA-PortSetNoTargetPort"); - Assert.Equal(AddressAllocationModes.Localhost, svc.Spec.AddressAllocationMode); - Assert.Equal(desiredPortOne, svc.Status?.EffectivePort); - Assert.True(spAnnList.Single(ann => ann.ServiceName == "ServiceA-PortSetNoTargetPort").Port is null, - "Expected service producer (target) port to not be set (leave allocation to DCP)"); - var envVarVal = ers.Spec.Template.Spec.Env?.Single(v => v.Name == "PORT_SET_NO_TARGET_PORT").Value; - Assert.False(string.IsNullOrWhiteSpace(envVarVal)); - Assert.Contains("""portForServing "ServiceA-PortSetNoTargetPort" """, envVarVal); - } - - /// - /// Verifies that applying unsupported endpoint port configuration to a Project resource results in an error. - /// - /// - /// Projects are run by DCP via ExecutableReplicaSet and must use a proxy to enable dynamic scaling. - /// Any Endpoint configuration that does not enable proxying should result in an error. - /// Similarly, specifying a TargetPort is not supported because each replica must get a distinct port. - /// - [Fact] - public async Task UnsupportedEndpointPortsProject() - { - const int desiredPortOne = TestKubernetesService.StartOfAutoPortRange - 1000; - const int desiredPortTwo = TestKubernetesService.StartOfAutoPortRange - 999; - const int desiredPortThree = TestKubernetesService.StartOfAutoPortRange - 998; - - (Action> AddEndpoint, string ErrorMessageFragment)[] testcases = [ - // Invalid configuration: TargetPort is set (Port left empty). - ( - pr => pr.WithEndpoint(name: "NoPortTargetPortSet", targetPort: desiredPortOne, env: "NO_PORT_TARGET_PORT_SET", isProxied: true), - "setting TargetPort is not allowed" - ), - - // Invalid configuration: both TargetPort and Port are set. - ( - pr => pr.WithEndpoint(name: "PortAndTargetPortSet", port: desiredPortTwo, targetPort: desiredPortThree, env: "PORT_AND_TARGET_PORT_SET", isProxied: true), - "setting TargetPort is not allowed" - ), - - // Invalid configuration: proxy-less endpoints and (replicated) projects do not work together - ( - pr => pr.WithEndpoint(name: "NoPortNoTargetPort", env: "NO_PORT_NO_TARGET_PORT", isProxied: false), - "features do not work together" - ) - ]; + var exes = kubernetesService.CreatedResources.OfType().ToList(); + Assert.Equal(3, exes.Count); - foreach (var tc in testcases) + foreach (var dcpExe in exes) { - var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions - { - AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName - }); - - // Invalid configuration: TargetPort is set (Port left empty). - - var pr = builder.AddProject("ServiceA"); - tc.AddEndpoint(pr); - pr.WithReplicas(3); - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, app.Services, kubernetesService: kubernetesService); - var exception = await Assert.ThrowsAsync(() => appExecutor.RunApplicationAsync()); - Assert.Contains(tc.ErrorMessageFragment, exception.Message); + Assert.True(dcpExe.TryGetAnnotationAsObjectList(CustomResource.ServiceProducerAnnotation, out var spAnnList)); + + // Port is set, but TargetPort is empty. + // Clients use proxy, MAY have the proxy port injected. + // Proxy uses Port. + // Each replica gets a different autogenerated port that MUST be injected via env var/startup param. + var svc = kubernetesService.CreatedResources.OfType().Single(s => s.Name() == "ServiceA-PortSetNoTargetPort"); + Assert.Equal(AddressAllocationModes.Localhost, svc.Spec.AddressAllocationMode); + Assert.Equal(desiredPortOne, svc.Status?.EffectivePort); + Assert.True(spAnnList.Single(ann => ann.ServiceName == "ServiceA-PortSetNoTargetPort").Port is null, + "Expected service producer (target) port to not be set (leave allocation to DCP)"); + var envVarVal = dcpExe.Spec.Env?.Single(v => v.Name == "PORT_SET_NO_TARGET_PORT").Value; + Assert.False(string.IsNullOrWhiteSpace(envVarVal)); + Assert.Contains("""portForServing "ServiceA-PortSetNoTargetPort" """, envVarVal); } } @@ -938,6 +895,24 @@ public async Task EndpointPortsContainerProxylessPortAndTargetPortSet() Assert.Equal(desiredTargetPort, int.Parse(envVarVal, CultureInfo.InvariantCulture)); } + [Fact] + public async Task ErrorIfResourceNotDeletedBeforeRestart() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddContainer("database", "image"); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, app.Services, kubernetesService: kubernetesService); + await appExecutor.RunApplicationAsync(); + + var dcpCtr = Assert.Single(kubernetesService.CreatedResources.OfType()); + + var ex = await Assert.ThrowsAsync(async () => await appExecutor.StartResourceAsync(dcpCtr.Metadata.Name, CancellationToken.None)); + Assert.Equal($"Failed to delete '{dcpCtr.Metadata.Name}' successfully before restart.", ex.Message); + } + private static ApplicationExecutor CreateAppExecutor( DistributedApplicationModel distributedAppModel, IServiceProvider serviceProvider, @@ -972,23 +947,11 @@ private static ApplicationExecutor CreateAppExecutor( { ServiceProvider = TestServiceProvider.Instance }), - new ResourceNotificationService(new NullLogger(), new TestHostApplicationLifetime()), + ResourceNotificationServiceTestHelpers.Create(), resourceLoggerService ?? new ResourceLoggerService(), new TestDcpDependencyCheckService(), new DistributedApplicationEventing(), serviceProvider ); } - - private sealed class TestHostApplicationLifetime : IHostApplicationLifetime - { - public CancellationToken ApplicationStarted { get; } - public CancellationToken ApplicationStopped { get; } - public CancellationToken ApplicationStopping { get; } - - public void StopApplication() - { - throw new NotImplementedException(); - } - } } diff --git a/tests/Aspire.Hosting.Tests/Dcp/TestKubernetesService.cs b/tests/Aspire.Hosting.Tests/Dcp/TestKubernetesService.cs index 5c0e74721d..7ecab7cc2e 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/TestKubernetesService.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/TestKubernetesService.cs @@ -9,6 +9,7 @@ using System.Runtime.CompilerServices; using System.Text.Json; using System.Text; +using k8s.Models; namespace Aspire.Hosting.Tests.Dcp; @@ -89,7 +90,7 @@ public void PushResourceModified(CustomResource resource) public Task DeleteAsync(string name, string? namespaceParameter = null, CancellationToken cancellationToken = default) where T : CustomResource { - throw new NotImplementedException(); + return GetAsync(name, namespaceParameter, cancellationToken); } public Task> ListAsync(string? namespaceParameter = null, CancellationToken cancellationToken = default) where T : CustomResource @@ -137,4 +138,9 @@ public Task GetLogStreamAsync(T obj, string logStreamType, bool? foll { return Task.FromResult(_startStream(obj, logStreamType)); } + + public Task PatchAsync(T obj, V1Patch patch, CancellationToken cancellationToken = default) where T : CustomResource + { + return Task.FromResult(obj); + } } diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs index b974a5637a..9115142b95 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs @@ -257,6 +257,83 @@ public async Task VerifyDockerAppWorks() await app.StopAsync(); } + [Fact] + [RequiresDocker] + [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))] + public async Task VerifyContainerStopStartWorks() + { + using var testProgram = CreateTestProgram(randomizePorts: false); + + testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper)); + + testProgram.AppBuilder.AddContainer("redis0", "redis") + .WithEndpoint(targetPort: 6379, name: "tcp", env: "REDIS_PORT"); + + await using var app = testProgram.Build(); + + var kubernetes = app.Services.GetRequiredService(); + var applicationExecutor = app.Services.GetRequiredService(); + var suffix = app.Services.GetRequiredService>().Value.ResourceNameSuffix; + + await app.StartAsync(); + + using var cts = new CancellationTokenSource(Debugger.IsAttached ? Timeout.InfiniteTimeSpan : TimeSpan.FromMinutes(1)); + var token = cts.Token; + + var containerPattern = $"redis0-{ReplicaIdRegex}-{suffix}"; + var redisContainer = await KubernetesHelper.GetResourceByNameMatchAsync(kubernetes, containerPattern, r => r.Status?.State == ContainerState.Running, token); + Assert.NotNull(redisContainer); + + await applicationExecutor.StopResourceAsync(redisContainer.Metadata.Name, token); + + redisContainer = await KubernetesHelper.GetResourceByNameMatchAsync(kubernetes, containerPattern, r => r.Status?.State == ContainerState.Exited, token); + Assert.NotNull(redisContainer); + + // TODO: Container start has issues in DCP. Waiting for fix. + //await applicationExecutor.StartResourceAsync(redisContainer.Metadata.Name, token); + + //redisContainer = await KubernetesHelper.GetResourceByNameMatchAsync(kubernetes, containerPattern, r => r.Status?.State == ContainerState.Running, token); + //Assert.NotNull(redisContainer); + + await app.StopAsync(); + } + + [Fact] + [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))] + public async Task VerifyExecutableStopStartWorks() + { + using var testProgram = CreateTestProgram(randomizePorts: false); + + testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper)); + + await using var app = testProgram.Build(); + + var kubernetes = app.Services.GetRequiredService(); + var applicationExecutor = app.Services.GetRequiredService(); + var suffix = app.Services.GetRequiredService>().Value.ResourceNameSuffix; + + await app.StartAsync(); + + using var cts = new CancellationTokenSource(Debugger.IsAttached ? Timeout.InfiniteTimeSpan : TimeSpan.FromMinutes(1)); + var token = cts.Token; + + var executablePattern = $"servicea-{ReplicaIdRegex}-{suffix}"; + var serviceA = await KubernetesHelper.GetResourceByNameMatchAsync(kubernetes, executablePattern, r => r.Status?.State == ExecutableState.Running, token); + Assert.NotNull(serviceA); + + await applicationExecutor.StopResourceAsync(serviceA.Metadata.Name, token); + + serviceA = await KubernetesHelper.GetResourceByNameMatchAsync(kubernetes, executablePattern, r => r.Status?.State == ExecutableState.Finished, token); + Assert.NotNull(serviceA); + + await applicationExecutor.StartResourceAsync(serviceA.Metadata.Name, token); + + serviceA = await KubernetesHelper.GetResourceByNameMatchAsync(kubernetes, executablePattern, r => r.Status?.State == ExecutableState.Running, token); + Assert.NotNull(serviceA); + + await app.StopAsync(); + } + [Fact] [RequiresDocker] [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))] diff --git a/tests/Aspire.Hosting.Tests/ResourceCommandAnnotationTests.cs b/tests/Aspire.Hosting.Tests/ResourceCommandAnnotationTests.cs new file mode 100644 index 0000000000..5bc76e0d84 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/ResourceCommandAnnotationTests.cs @@ -0,0 +1,90 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Aspire.Hosting.Tests; + +public class ResourceCommandAnnotationTests +{ + [Fact] + public void AddContainer_HasKnownCommandAnnotations() + { + HasKnownCommandAnnotationsCore(builder => builder.AddContainer("name", "image")); + } + + [Fact] + public void AddProject_HasKnownCommandAnnotations() + { + HasKnownCommandAnnotationsCore(builder => builder.AddProject("name", "path", o => o.ExcludeLaunchProfile = true)); + } + + [Fact] + public void AddExecutable_HasKnownCommandAnnotations() + { + HasKnownCommandAnnotationsCore(builder => builder.AddExecutable("name", "command", "workingDirectory")); + } + + [Theory] + [InlineData(CommandsConfigurationExtensions.StartType, "Starting", ResourceCommandState.Disabled)] + [InlineData(CommandsConfigurationExtensions.StartType, "Stopping", ResourceCommandState.Hidden)] + [InlineData(CommandsConfigurationExtensions.StartType, "Running", ResourceCommandState.Hidden)] + [InlineData(CommandsConfigurationExtensions.StartType, "Exited", ResourceCommandState.Enabled)] + [InlineData(CommandsConfigurationExtensions.StartType, "Finished", ResourceCommandState.Enabled)] + [InlineData(CommandsConfigurationExtensions.StartType, "FailedToStart", ResourceCommandState.Enabled)] + [InlineData(CommandsConfigurationExtensions.StartType, "Waiting", ResourceCommandState.Hidden)] + [InlineData(CommandsConfigurationExtensions.StopType, "Starting", ResourceCommandState.Hidden)] + [InlineData(CommandsConfigurationExtensions.StopType, "Stopping", ResourceCommandState.Disabled)] + [InlineData(CommandsConfigurationExtensions.StopType, "Running", ResourceCommandState.Enabled)] + [InlineData(CommandsConfigurationExtensions.StopType, "Exited", ResourceCommandState.Hidden)] + [InlineData(CommandsConfigurationExtensions.StopType, "Finished", ResourceCommandState.Hidden)] + [InlineData(CommandsConfigurationExtensions.StopType, "FailedToStart", ResourceCommandState.Hidden)] + [InlineData(CommandsConfigurationExtensions.StopType, "Waiting", ResourceCommandState.Disabled)] + [InlineData(CommandsConfigurationExtensions.RestartType, "Starting", ResourceCommandState.Disabled)] + [InlineData(CommandsConfigurationExtensions.RestartType, "Stopping", ResourceCommandState.Disabled)] + [InlineData(CommandsConfigurationExtensions.RestartType, "Running", ResourceCommandState.Enabled)] + [InlineData(CommandsConfigurationExtensions.RestartType, "Exited", ResourceCommandState.Disabled)] + [InlineData(CommandsConfigurationExtensions.RestartType, "Finished", ResourceCommandState.Disabled)] + [InlineData(CommandsConfigurationExtensions.RestartType, "FailedToStart", ResourceCommandState.Disabled)] + [InlineData(CommandsConfigurationExtensions.RestartType, "Waiting", ResourceCommandState.Disabled)] + public void LifeCycleCommands_CommandState(string commandType, string resourceState, ResourceCommandState commandState) + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + var resourceBuilder = builder.AddContainer("name", "image"); + + var startCommand = resourceBuilder.Resource.Annotations.OfType().Single(a => a.Type == commandType); + + // Act + var state = startCommand.UpdateState(new UpdateCommandStateContext + { + ResourceSnapshot = new CustomResourceSnapshot + { + Properties = [], + ResourceType = "test", + State = resourceState + }, + ServiceProvider = new ServiceCollection().BuildServiceProvider() + }); + + // Assert + Assert.Equal(commandState, state); + } + + private static void HasKnownCommandAnnotationsCore(Func> createResourceBuilder) where T : IResource + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + + // Act + var resourceBuilder = createResourceBuilder(builder); + + // Assert + var commandAnnotations = resourceBuilder.Resource.Annotations.OfType().ToList(); + Assert.Collection(commandAnnotations, + a => Assert.Equal(CommandsConfigurationExtensions.StartType, a.Type), + a => Assert.Equal(CommandsConfigurationExtensions.StopType, a.Type), + a => Assert.Equal(CommandsConfigurationExtensions.RestartType, a.Type)); + } +} diff --git a/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs b/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs index b25087368c..a0b4da4d2f 100644 --- a/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs @@ -4,6 +4,7 @@ using Xunit; namespace Aspire.Hosting.Tests; + public class ResourceExtensionsTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs index d4821188aa..76401d873f 100644 --- a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs @@ -1,9 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Hosting.Tests.Utils; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; using Xunit; @@ -45,7 +45,7 @@ public async Task ResourceUpdatesAreQueued() { var resource = new CustomResource("myResource"); - var notificationService = new ResourceNotificationService(new NullLogger(), new TestHostApplicationLifetime()); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); async Task> GetValuesAsync(CancellationToken cancellationToken) { @@ -96,7 +96,7 @@ public async Task WatchingAllResourcesNotifiesOfAnyResourceChange() var resource1 = new CustomResource("myResource1"); var resource2 = new CustomResource("myResource2"); - var notificationService = new ResourceNotificationService(new NullLogger(), new TestHostApplicationLifetime()); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); async Task> GetValuesAsync(CancellationToken cancellation) { @@ -155,7 +155,7 @@ public async Task WaitingOnResourceReturnsWhenResourceReachesTargetState() { var resource1 = new CustomResource("myResource1"); - var notificationService = new ResourceNotificationService(new NullLogger(), new TestHostApplicationLifetime()); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var waitTask = notificationService.WaitForResourceAsync("myResource1", "SomeState", cts.Token); @@ -171,7 +171,7 @@ public async Task WaitingOnResourceReturnsWhenResourceReachesTargetStateWithDiff { var resource1 = new CustomResource("myResource1"); - var notificationService = new ResourceNotificationService(new NullLogger(), new TestHostApplicationLifetime()); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var waitTask = notificationService.WaitForResourceAsync("MYreSouRCe1", "sOmeSTAtE", cts.Token); @@ -187,7 +187,7 @@ public async Task WaitingOnResourceReturnsImmediatelyWhenResourceIsInTargetState { var resource1 = new CustomResource("myResource1"); - var notificationService = new ResourceNotificationService(new NullLogger(), new TestHostApplicationLifetime()); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); // Publish the state update first await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { State = "SomeState" }); @@ -203,7 +203,7 @@ public async Task WaitingOnResourceReturnsWhenResourceReachesRunningStateIfNoTar { var resource1 = new CustomResource("myResource1"); - var notificationService = new ResourceNotificationService(new NullLogger(), new TestHostApplicationLifetime()); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var waitTask = notificationService.WaitForResourceAsync("myResource1", targetState: null, cancellationToken: cts.Token); @@ -219,7 +219,7 @@ public async Task WaitingOnResourceReturnsCorrectStateWhenResourceReachesOneOfTa { var resource1 = new CustomResource("myResource1"); - var notificationService = new ResourceNotificationService(new NullLogger(), new TestHostApplicationLifetime()); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var waitTask = notificationService.WaitForResourceAsync("myResource1", ["SomeState", "SomeOtherState"], cts.Token); @@ -235,7 +235,7 @@ public async Task WaitingOnResourceReturnsCorrectStateWhenResourceReachesOneOfTa { var resource1 = new CustomResource("myResource1"); - var notificationService = new ResourceNotificationService(new NullLogger(), new TestHostApplicationLifetime()); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); var waitTask = notificationService.WaitForResourceAsync("myResource1", ["SomeState", "SomeOtherState"], default); @@ -248,7 +248,7 @@ public async Task WaitingOnResourceReturnsCorrectStateWhenResourceReachesOneOfTa [Fact] public async Task WaitingOnResourceThrowsOperationCanceledExceptionIfResourceDoesntReachStateBeforeCancellationTokenSignaled() { - var notificationService = new ResourceNotificationService(new NullLogger(), new TestHostApplicationLifetime()); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); using var cts = new CancellationTokenSource(); var waitTask = notificationService.WaitForResourceAsync("myResource1", "SomeState", cts.Token); @@ -265,7 +265,7 @@ await Assert.ThrowsAsync(async () => public async Task WaitingOnResourceThrowsOperationCanceledExceptionIfResourceDoesntReachStateBeforeApplicationStoppingCancellationTokenSignaled() { using var hostApplicationLifetime = new TestHostApplicationLifetime(); - var notificationService = new ResourceNotificationService(new NullLogger(), hostApplicationLifetime); + var notificationService = ResourceNotificationServiceTestHelpers.Create(hostApplicationLifetime: hostApplicationLifetime); var waitTask = notificationService.WaitForResourceAsync("myResource1", "SomeState"); hostApplicationLifetime.StopApplication(); @@ -280,7 +280,7 @@ await Assert.ThrowsAsync(async () => public async Task WaitingOnResourceThrowsOperationCanceledExceptionIfResourceDoesntReachStateBeforeCancellationTokenSignalledWhenApplicationStoppingTokenExists() { using var hostApplicationLifetime = new TestHostApplicationLifetime(); - var notificationService = new ResourceNotificationService(new NullLogger(), hostApplicationLifetime); + var notificationService = ResourceNotificationServiceTestHelpers.Create(hostApplicationLifetime: hostApplicationLifetime); using var cts = new CancellationTokenSource(); var waitTask = notificationService.WaitForResourceAsync("myResource1", "SomeState", cts.Token); @@ -298,7 +298,7 @@ public async Task PublishLogsStateTextChangesCorrectly() { var resource1 = new CustomResource("resource1"); var logger = new FakeLogger(); - var notificationService = new ResourceNotificationService(logger, new TestHostApplicationLifetime()); + var notificationService = ResourceNotificationServiceTestHelpers.Create(logger: logger); await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { State = "SomeState" }); @@ -366,7 +366,7 @@ public async Task PublishLogsTraceStateDetailsCorrectly() { var resource1 = new CustomResource("resource1"); var logger = new FakeLogger(); - var notificationService = new ResourceNotificationService(logger, new TestHostApplicationLifetime()); + var notificationService = ResourceNotificationServiceTestHelpers.Create(logger: logger); var createdDate = DateTime.Now; await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { CreationTimeStamp = createdDate }); diff --git a/tests/Aspire.Hosting.Tests/Utils/ResourceNotificationServiceTestHelpers.cs b/tests/Aspire.Hosting.Tests/Utils/ResourceNotificationServiceTestHelpers.cs new file mode 100644 index 0000000000..0f8d02dd84 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Utils/ResourceNotificationServiceTestHelpers.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Hosting.Tests.Utils; + +public static class ResourceNotificationServiceTestHelpers +{ + internal static ResourceNotificationService Create(ILogger? logger = null, IHostApplicationLifetime? hostApplicationLifetime = null) + { + return new ResourceNotificationService( + logger ?? new NullLogger(), + hostApplicationLifetime ?? new TestHostApplicationLifetime(), + TestServiceProvider.Instance); + } +} diff --git a/tests/Aspire.Hosting.Tests/Utils/TestHostApplicationLifetime.cs b/tests/Aspire.Hosting.Tests/Utils/TestHostApplicationLifetime.cs new file mode 100644 index 0000000000..03d0b8d39c --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Utils/TestHostApplicationLifetime.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Hosting; + +namespace Aspire.Hosting.Tests.Utils; + +public sealed class TestHostApplicationLifetime : IHostApplicationLifetime +{ + public CancellationToken ApplicationStarted { get; } + public CancellationToken ApplicationStopped { get; } + public CancellationToken ApplicationStopping { get; } + + public void StopApplication() + { + throw new NotImplementedException(); + } +} diff --git a/tests/Aspire.Hosting.Tests/Utils/TestServiceProvider.cs b/tests/Aspire.Hosting.Tests/Utils/TestServiceProvider.cs index aa801bb26f..b4619e78f0 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestServiceProvider.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestServiceProvider.cs @@ -6,9 +6,11 @@ using Aspire.Hosting.Tests.Dcp; namespace Aspire.Hosting.Tests.Utils; + public sealed class TestServiceProvider : IServiceProvider { private readonly ServiceContainer _serviceContainer = new ServiceContainer(); + private TestServiceProvider() { _serviceContainer.AddService(typeof(IDcpDependencyCheckService), new TestDcpDependencyCheckService());