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