Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion playground/Stress/Stress.AppHost/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;

Expand Down Expand Up @@ -124,7 +125,51 @@
await ExecuteCommandForAllResourcesAsync(c.ServiceProvider, KnownResourceCommands.StartCommand, c.CancellationToken);
return CommandResults.Success();
},
commandOptions: new() { IconName = "Play", IconVariant = IconVariant.Filled });
commandOptions: new() { IconName = "Play", IconVariant = IconVariant.Filled })
.WithCommand(
name: "generate-token",
displayName: "Generate Token",
executeCommand: (c) =>
{
var token = new
{
accessToken = Convert.ToBase64String(Guid.NewGuid().ToByteArray()) + Convert.ToBase64String(Guid.NewGuid().ToByteArray()),
tokenType = "Bearer",
expiresIn = 3600,
scope = "api.read api.write",
issuedAt = DateTime.UtcNow
};
var json = JsonSerializer.Serialize(token, new JsonSerializerOptions { WriteIndented = true });
return Task.FromResult(CommandResults.Success(json, CommandResultFormat.Json));
},
commandOptions: new() { IconName = "Key", Description = "Generate a temporary access token" })
.WithCommand(
name: "get-connection-string",
displayName: "Get Connection String",
executeCommand: (c) =>
{
var connectionString = $"Server=localhost,1433;Database=StressDb;User Id=sa;Password={Guid.NewGuid():N};TrustServerCertificate=true";
return Task.FromResult(CommandResults.Success(connectionString, CommandResultFormat.Text));
},
commandOptions: new() { IconName = "LinkMultiple", Description = "Get the connection string for this resource" })
.WithCommand(
name: "validate-config",
displayName: "Validate Config",
executeCommand: (c) =>
{
var errors = new { errors = new[] { new { field = "connectionString", message = "Invalid host" }, new { field = "timeout", message = "Must be positive" } } };
var json = JsonSerializer.Serialize(errors, new JsonSerializerOptions { WriteIndented = true });
return Task.FromResult(CommandResults.Failure("Validation failed", json, CommandResultFormat.Json));
},
commandOptions: new() { IconName = "Warning", Description = "Validate resource configuration (always fails with details)" })
.WithCommand(
name: "check-health",
displayName: "Check Health",
executeCommand: (c) =>
{
return Task.FromResult(CommandResults.Failure("Health check failed", "Connection refused: ECONNREFUSED 127.0.0.1:5432\nRetries exhausted after 3 attempts", CommandResultFormat.Text));
},
commandOptions: new() { IconName = "HeartBroken", Description = "Check resource health (always fails with details)" });

#if !SKIP_DASHBOARD_REFERENCE
// This project is only added in playground projects to support development/debugging
Expand Down
21 changes: 17 additions & 4 deletions src/Aspire.Cli/Commands/ResourceCommandHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,16 @@ public static async Task<int> ExecuteGenericCommandAsync(
{
logger.LogDebug("Executing command '{CommandName}' on resource '{ResourceName}'", commandName, resourceName);

// Route status messages to stderr so command results in stdout remain pipeable (e.g., | jq)
interactionService.Console = ConsoleOutput.Error;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't seem right, shouldn't we be adding a parameter to ShowStatusAsync that accepts ConsoleOutput, like DisplayRawText does?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No we want all messages to go to stderr and output to be stdout. This is similar to what we do when —format json is specified


var response = await interactionService.ShowStatusAsync(
$"Executing command '{commandName}' on resource '{resourceName}'...",
async () => await connection.ExecuteResourceCommandAsync(resourceName, commandName, cancellationToken));

if (response.Success)
{
interactionService.DisplaySuccess($"Command '{commandName}' executed successfully on resource '{resourceName}'.");
return ExitCodeConstants.Success;
}
else if (response.Canceled)
{
Expand All @@ -77,8 +79,14 @@ public static async Task<int> ExecuteGenericCommandAsync(
{
var errorMessage = GetFriendlyErrorMessage(response.ErrorMessage);
interactionService.DisplayError($"Failed to execute command '{commandName}' on resource '{resourceName}': {errorMessage}");
return ExitCodeConstants.FailedToExecuteResourceCommand;
}

if (response.Result is not null)
{
interactionService.DisplayRawText(response.Result, ConsoleOutput.Standard);
}

return response.Success ? ExitCodeConstants.Success : ExitCodeConstants.FailedToExecuteResourceCommand;
}

private static int HandleResponse(
Expand All @@ -92,7 +100,6 @@ private static int HandleResponse(
if (response.Success)
{
interactionService.DisplaySuccess($"Resource '{resourceName}' {pastTenseVerb} successfully.");
return ExitCodeConstants.Success;
}
else if (response.Canceled)
{
Expand All @@ -103,8 +110,14 @@ private static int HandleResponse(
{
var errorMessage = GetFriendlyErrorMessage(response.ErrorMessage);
interactionService.DisplayError($"Failed to {baseVerb} resource '{resourceName}': {errorMessage}");
return ExitCodeConstants.FailedToExecuteResourceCommand;
}

if (response.Result is not null)
{
interactionService.DisplayRawText(response.Result, ConsoleOutput.Standard);
}

return response.Success ? ExitCodeConstants.Success : ExitCodeConstants.FailedToExecuteResourceCommand;
}

private static string GetFriendlyErrorMessage(string? errorMessage)
Expand Down
29 changes: 27 additions & 2 deletions src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,19 @@ public override async ValueTask<CallToolResult> CallToolAsync(CallToolContext co

if (response.Success)
{
var content = new List<TextContentBlock>
{
new() { Text = $"Command '{commandName}' executed successfully on resource '{resourceName}'." }
};

if (response.Result is not null)
{
content.Add(new TextContentBlock { Text = response.Result });
}

return new CallToolResult
{
Content = [new TextContentBlock { Text = $"Command '{commandName}' executed successfully on resource '{resourceName}'." }]
Content = [.. content]
};
}
else if (response.Canceled)
Expand All @@ -86,7 +96,22 @@ public override async ValueTask<CallToolResult> CallToolAsync(CallToolContext co
else
{
var message = response.ErrorMessage is { Length: > 0 } ? response.ErrorMessage : "Unknown error. See logs for details.";
throw new McpProtocolException($"Command '{commandName}' failed for resource '{resourceName}': {message}", McpErrorCode.InternalError);

var content = new List<TextContentBlock>
{
new() { Text = $"Command '{commandName}' failed for resource '{resourceName}': {message}" }
};

if (response.Result is not null)
{
content.Add(new TextContentBlock { Text = response.Result });
}

return new CallToolResult
{
IsError = true,
Content = [.. content]
};
}
}
catch (McpProtocolException)
Expand Down
15 changes: 14 additions & 1 deletion src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Globalization;
using Aspire.Dashboard.Components.Dialogs;
using Aspire.Dashboard.Telemetry;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
Expand All @@ -13,7 +14,7 @@ namespace Aspire.Dashboard.Model;

public sealed class DashboardCommandExecutor(
IDashboardClient dashboardClient,
IDialogService dialogService,
DashboardDialogService dialogService,
IToastService toastService,
IStringLocalizer<Dashboard.Resources.Resources> loc,
NavigationManager navigationManager,
Expand Down Expand Up @@ -168,6 +169,18 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel
toastParameters.OnPrimaryAction = EventCallback.Factory.Create<ToastResult>(this, () => navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: getResourceName(resource))));
}

if (response.Result is not null)
{
var fixedFormat = response.ResultFormat == CommandResultFormat.Json ? DashboardUIHelpers.JsonFormat : null;
await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions
{
DialogService = dialogService,
ValueDescription = command.GetDisplayName(),
Value = response.Result,
FixedFormat = fixedFormat
}).ConfigureAwait(false);
}

if (!toastClosed)
{
// Extend cancel time.
Expand Down
18 changes: 18 additions & 0 deletions src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ public class ResourceCommandResponseViewModel
{
public required ResourceCommandResponseKind Kind { get; init; }
public string? ErrorMessage { get; init; }
public string? Result { get; init; }
public CommandResultFormat? ResultFormat { get; init; }
}

// Must be kept in sync with ResourceCommandResponseKind in the resource_service.proto file
Expand All @@ -17,3 +19,19 @@ public enum ResourceCommandResponseKind
Failed = 2,
Cancelled = 3
}

/// <summary>
/// Specifies the format of a command result.
/// </summary>
public enum CommandResultFormat
{
/// <summary>
/// Plain text result.
/// </summary>
Text,

/// <summary>
/// JSON result.
/// </summary>
Json
}
9 changes: 8 additions & 1 deletion src/Aspire.Dashboard/ServiceClient/Partials.cs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,14 @@ public ResourceCommandResponseViewModel ToViewModel()
return new ResourceCommandResponseViewModel()
{
ErrorMessage = ErrorMessage,
Kind = (Dashboard.Model.ResourceCommandResponseKind)Kind
Kind = (Dashboard.Model.ResourceCommandResponseKind)Kind,
Result = HasResult ? Result : null,
ResultFormat = ResultFormat switch
{
CommandResultFormat.Text => Dashboard.Model.CommandResultFormat.Text,
CommandResultFormat.Json => Dashboard.Model.CommandResultFormat.Json,
_ => null
}
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,22 @@ public enum IconVariant
Filled
}

/// <summary>
/// Specifies the format of a command result.
/// </summary>
public enum CommandResultFormat
{
/// <summary>
/// Plain text result.
/// </summary>
Text,

/// <summary>
/// JSON result.
/// </summary>
Json
}

/// <summary>
/// A factory for <see cref="ExecuteCommandResult"/>.
/// </summary>
Expand All @@ -124,12 +140,27 @@ public static class CommandResults
/// </summary>
public static ExecuteCommandResult Success() => new() { Success = true };

/// <summary>
/// Produces a success result with result data.
/// </summary>
/// <param name="result">The result data.</param>
/// <param name="resultFormat">The format of the result data. Defaults to <see cref="CommandResultFormat.Text"/>.</param>
public static ExecuteCommandResult Success(string result, CommandResultFormat resultFormat = CommandResultFormat.Text) => new() { Success = true, Result = result, ResultFormat = resultFormat };
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should result be nullable here, to match that we allow null results in the model?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentionally non-nullable — if you have no result, call Success(). This overload is for when you explicitly have data to return. Passing null here would be a caller bug.


/// <summary>
/// Produces an unsuccessful result with an error message.
/// </summary>
/// <param name="errorMessage">An optional error message.</param>
public static ExecuteCommandResult Failure(string? errorMessage = null) => new() { Success = false, ErrorMessage = errorMessage };

/// <summary>
/// Produces an unsuccessful result with an error message and result data.
/// </summary>
/// <param name="errorMessage">The error message.</param>
/// <param name="result">The result data.</param>
/// <param name="resultFormat">The format of the result data. Defaults to <see cref="CommandResultFormat.Text"/>.</param>
public static ExecuteCommandResult Failure(string errorMessage, string result, CommandResultFormat resultFormat = CommandResultFormat.Text) => new() { Success = false, ErrorMessage = errorMessage, Result = result, ResultFormat = resultFormat };
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, should errorMessage and result be nullable?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same reasoning — this overload is for when you explicitly have both an error message and result data. The existing Failure(string?) handles the no-result case.


/// <summary>
/// Produces a canceled result.
/// </summary>
Expand Down Expand Up @@ -162,6 +193,16 @@ public sealed class ExecuteCommandResult
/// An optional error message that can be set when the command is unsuccessful.
/// </summary>
public string? ErrorMessage { get; init; }

/// <summary>
/// An optional result value produced by the command.
/// </summary>
public string? Result { get; init; }

/// <summary>
/// The format of the <see cref="Result"/> value.
/// </summary>
public CommandResultFormat? ResultFormat { get; init; }
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,13 @@ public async Task<ExecuteCommandResult> ExecuteCommandAsync(IResource resource,

if (failures.Count == 0 && cancellations.Count == 0)
{
return new ExecuteCommandResult { Success = true };
var successWithResult = results.FirstOrDefault(r => r.Result is not null);
return new ExecuteCommandResult
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if there are different replicas of a resource? Should the result aggregate all results and send those back in an array of results or something like that?

Copy link
Copy Markdown
Member

@adamint adamint Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

re @joperezr's replicas comment, yes, it should - maybe also have the ability to run a command on only one instance?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently takes the first non-null result from replicas. Aggregating into an array would change the result contract and add complexity. Running on a single instance is a separate feature. Both are worth tracking as follow-ups but out of scope for v1.

{
Success = true,
Result = successWithResult?.Result,
ResultFormat = successWithResult?.ResultFormat
};
}
else if (failures.Count == 0 && cancellations.Count > 0)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,9 @@ public async Task<ExecuteResourceCommandResponse> ExecuteResourceCommandAsync(Ex
{
Success = result.Success,
Canceled = result.Canceled,
ErrorMessage = result.ErrorMessage
ErrorMessage = result.ErrorMessage,
Result = result.Result,
ResultFormat = result.ResultFormat?.ToString().ToLowerInvariant()
};
}

Expand Down
10 changes: 10 additions & 0 deletions src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,16 @@ internal sealed class ExecuteResourceCommandResponse
/// Gets the error message if the command failed.
/// </summary>
public string? ErrorMessage { get; init; }

/// <summary>
/// Gets the result data produced by the command.
/// </summary>
public string? Result { get; init; }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enapsulate w/format?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same — encapsulation is a good future refactor.


/// <summary>
/// Gets the format of the result data (e.g. "none", "text", "json").
/// </summary>
public string? ResultFormat { get; init; }
}

#endregion
Expand Down
19 changes: 16 additions & 3 deletions src/Aspire.Hosting/Dashboard/DashboardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ async Task WatchResourceConsoleLogsInternal(bool suppressFollow, CancellationTok

public override async Task<ResourceCommandResponse> ExecuteResourceCommand(ResourceCommandRequest request, ServerCallContext context)
{
var (result, errorMessage) = await serviceData.ExecuteCommandAsync(request.ResourceName, request.CommandName, context.CancellationToken).ConfigureAwait(false);
var (result, errorMessage, commandResult, resultFormat) = await serviceData.ExecuteCommandAsync(request.ResourceName, request.CommandName, context.CancellationToken).ConfigureAwait(false);
var responseKind = result switch
{
ExecuteCommandResultType.Success => ResourceCommandResponseKind.Succeeded,
Expand All @@ -369,11 +369,24 @@ public override async Task<ResourceCommandResponse> ExecuteResourceCommand(Resou
_ => ResourceCommandResponseKind.Undefined
};

return new ResourceCommandResponse
var response = new ResourceCommandResponse
{
Kind = responseKind,
ErrorMessage = errorMessage ?? string.Empty
ErrorMessage = errorMessage ?? string.Empty,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this also use null to represent "no error message"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a grpc quirk

ResultFormat = resultFormat switch
{
ApplicationModel.CommandResultFormat.Text => Aspire.DashboardService.Proto.V1.CommandResultFormat.Text,
ApplicationModel.CommandResultFormat.Json => Aspire.DashboardService.Proto.V1.CommandResultFormat.Json,
_ => Aspire.DashboardService.Proto.V1.CommandResultFormat.None
}
};

if (commandResult is not null)
{
response.Result = commandResult;
}

return response;
}

private async Task ExecuteAsync(Func<CancellationToken, Task> execute, ServerCallContext serverCallContext)
Expand Down
Loading
Loading