diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs
index 7b45dd367d7..0e21dacbf8d 100644
--- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs
@@ -25,7 +25,20 @@ internal ResourceCommandService(ResourceNotificationService resourceNotification
///
/// Execute a command for the specified resource.
///
- /// The id of the resource.
+ ///
+ ///
+ /// A resource id can be either the unique id of the resource or the displayed resource name.
+ ///
+ ///
+ /// Projects, executables and containers typically have a unique id that combines the display name and a unique suffix. For example, a resource named cache could have a resource id of cache-abcdwxyz.
+ /// This id is used to uniquely identify the resource in the app host.
+ ///
+ ///
+ /// The resource name can be also be used to retrieve the resource state, but it must be unique. If there are multiple resources with the same name, then this method will not return a match.
+ /// For example, if a resource named cache has multiple replicas, then specifing cache won't return a match.
+ ///
+ ///
+ /// The resource id. This id can either exactly match the unique id of the resource or the displayed resource name if the resource name doesn't have duplicates (i.e. replicas).
/// The command name.
/// The cancellation token.
/// The indicates command success or failure.
diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs
index 1ae6be4294f..bdc906e4eb8 100644
--- a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs
@@ -434,11 +434,25 @@ private async Task WaitForResourceCoreAsync(string resourceName,
///
/// Attempts to retrieve the current state of a resource by resourceId.
///
- /// The resource id.
+ ///
+ ///
+ /// A resource id can be either the unique id of the resource or the displayed resource name.
+ ///
+ ///
+ /// Projects, executables and containers typically have a unique id that combines the display name and a unique suffix. For example, a resource named cache could have a resource id of cache-abcdwxyz.
+ /// This id is used to uniquely identify the resource in the app host.
+ ///
+ ///
+ /// The resource name can be also be used to retrieve the resource state, but it must be unique. If there are multiple resources with the same name, then this method will not return a match.
+ /// For example, if a resource named cache has multiple replicas, then specifing cache won't return a match.
+ ///
+ ///
+ /// The resource id. This id can either exactly match the unique id of the resource or the displayed resource name if the resource name doesn't have duplicates (i.e. replicas).
/// When this method returns, contains the for the specified resource id, if found; otherwise, .
/// if specified resource id was found; otherwise, .
public bool TryGetCurrentState(string resourceId, [NotNullWhen(true)] out ResourceEvent? resourceEvent)
{
+ // Find exact match.
if (_resourceNotificationStates.TryGetValue(resourceId, out var state))
{
if (state.LastSnapshot is { } snapshot)
@@ -448,6 +462,29 @@ public bool TryGetCurrentState(string resourceId, [NotNullWhen(true)] out Resour
}
}
+ // Fallback to finding match on resource name. If there are multiple resources with the same name (e.g. replicas) then don't match.
+ KeyValuePair? nameMatch = null;
+ foreach (var matchingResource in _resourceNotificationStates.Where(s => string.Equals(s.Value.Resource.Name, resourceId, StringComparisons.ResourceName)))
+ {
+ if (nameMatch == null)
+ {
+ nameMatch = matchingResource;
+ }
+ else
+ {
+ // Second match found, so we can't return a match based on the name.
+ nameMatch = null;
+ break;
+ }
+ }
+
+ if (nameMatch is { } m && m.Value.LastSnapshot != null)
+ {
+ resourceEvent = new ResourceEvent(m.Value.Resource, m.Key, m.Value.LastSnapshot);
+ return true;
+ }
+
+ // No match.
resourceEvent = null;
return false;
}
diff --git a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs
index 831cdbedf7e..a57048bd01a 100644
--- a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs
+++ b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs
@@ -30,6 +30,29 @@ public async Task ExecuteCommandAsync_NoMatchingResource_Failure()
Assert.Equal("Resource 'NotFoundResourceId' not found.", result.ErrorMessage);
}
+ [Fact]
+ public async Task ExecuteCommandAsync_ResourceNameMultipleMatches_Failure()
+ {
+ // Arrange
+ using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
+
+ var custom = builder.AddResource(new CustomResource("myResource"));
+ custom.WithAnnotation(new DcpInstancesAnnotation([
+ new DcpInstance("myResource-abcdwxyz", "abcdwxyz", 0),
+ new DcpInstance("myResource-efghwxyz", "efghwxyz", 1)
+ ]));
+
+ var app = builder.Build();
+ await app.StartAsync();
+
+ // Act
+ var result = await app.ResourceCommands.ExecuteCommandAsync("myResource", "NotFound");
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.Equal("Resource 'myResource' not found.", result.ErrorMessage);
+ }
+
[Fact]
public async Task ExecuteCommandAsync_NoMatchingCommand_Failure()
{
@@ -49,6 +72,43 @@ public async Task ExecuteCommandAsync_NoMatchingCommand_Failure()
Assert.Equal("Command 'NotFound' not available for resource 'myResource'.", result.ErrorMessage);
}
+ [Fact]
+ public async Task ExecuteCommandAsync_ResourceNameMultipleMatches_Success()
+ {
+ // Arrange
+ using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
+
+ var commandResourcesChannel = Channel.CreateUnbounded();
+
+ var custom = builder.AddResource(new CustomResource("myResource"));
+ custom.WithAnnotation(new DcpInstancesAnnotation([
+ new DcpInstance("myResource-abcdwxyz", "abcdwxyz", 0)
+ ]));
+ custom.WithCommand(name: "mycommand",
+ displayName: "My command",
+ executeCommand: async e =>
+ {
+ await commandResourcesChannel.Writer.WriteAsync(e.ResourceName);
+ return new ExecuteCommandResult { Success = true };
+ });
+
+ var app = builder.Build();
+ await app.StartAsync();
+
+ // Act
+ var result = await app.ResourceCommands.ExecuteCommandAsync("myResource", "mycommand");
+ commandResourcesChannel.Writer.Complete();
+
+ // Assert
+ Assert.True(result.Success);
+
+ var resolvedResourceNames = custom.Resource.GetResolvedResourceNames().ToList();
+ await foreach (var resourceName in commandResourcesChannel.Reader.ReadAllAsync().DefaultTimeout())
+ {
+ Assert.True(resolvedResourceNames.Remove(resourceName));
+ }
+ }
+
[Fact]
[QuarantinedTest("https://github.com/dotnet/aspire/issues/9832")]
public async Task ExecuteCommandAsync_HasReplicas_Success_CalledPerReplica()