From b4c62a75a2ac0ba38290051e913233fd60f54e5e Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 14 Jul 2025 07:58:18 +0800 Subject: [PATCH 1/3] Match name with ResourceCommandService --- .../ResourceCommandService.cs | 15 ++++- .../ResourceNotificationService.cs | 39 +++++++++++- .../ResourceCommandServiceTests.cs | 60 +++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs index 7b45dd367d7..2a432960a4a 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 duplicas (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..05f4db97876 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 duplicas (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..80c87ebf1f4 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", 0), + new DcpInstance("myResource", "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", 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() From f0fae859569680b51ea12b64ca9e77fceb5f89f6 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 14 Jul 2025 08:06:04 +0800 Subject: [PATCH 2/3] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs | 2 +- .../ApplicationModel/ResourceNotificationService.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs index 2a432960a4a..0e21dacbf8d 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs @@ -38,7 +38,7 @@ internal ResourceCommandService(ResourceNotificationService resourceNotification /// 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 duplicas (i.e. replicas). + /// 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 05f4db97876..bdc906e4eb8 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs @@ -447,7 +447,7 @@ private async Task WaitForResourceCoreAsync(string resourceName, /// 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 duplicas (i.e. replicas). + /// 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) From ea42b47eec8e08f21d5e64c0db0e27d84fe9a00b Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 14 Jul 2025 12:30:58 +0800 Subject: [PATCH 3/3] Fix tests --- tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs index 80c87ebf1f4..a57048bd01a 100644 --- a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs @@ -38,8 +38,8 @@ public async Task ExecuteCommandAsync_ResourceNameMultipleMatches_Failure() var custom = builder.AddResource(new CustomResource("myResource")); custom.WithAnnotation(new DcpInstancesAnnotation([ - new DcpInstance("myResource", "abcdwxyz", 0), - new DcpInstance("myResource", "efghwxyz", 1) + new DcpInstance("myResource-abcdwxyz", "abcdwxyz", 0), + new DcpInstance("myResource-efghwxyz", "efghwxyz", 1) ])); var app = builder.Build(); @@ -82,7 +82,7 @@ public async Task ExecuteCommandAsync_ResourceNameMultipleMatches_Success() var custom = builder.AddResource(new CustomResource("myResource")); custom.WithAnnotation(new DcpInstancesAnnotation([ - new DcpInstance("myResource", "abcdwxyz", 0) + new DcpInstance("myResource-abcdwxyz", "abcdwxyz", 0) ])); custom.WithCommand(name: "mycommand", displayName: "My command",