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
15 changes: 14 additions & 1 deletion src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,20 @@ internal ResourceCommandService(ResourceNotificationService resourceNotification
/// <summary>
/// Execute a command for the specified resource.
/// </summary>
/// <param name="resourceId">The id of the resource.</param>
/// <remarks>
/// <para>
/// A resource id can be either the unique id of the resource or the displayed resource name.
/// </para>
/// <para>
/// Projects, executables and containers typically have a unique id that combines the display name and a unique suffix. For example, a resource named <c>cache</c> could have a resource id of <c>cache-abcdwxyz</c>.
/// This id is used to uniquely identify the resource in the app host.
/// </para>
/// <para>
/// 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 <c>cache</c> has multiple replicas, then specifing <c>cache</c> won't return a match.
/// </para>
/// </remarks>
/// <param name="resourceId">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).</param>
/// <param name="commandName">The command name.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The <see cref="ExecuteCommandResult" /> indicates command success or failure.</returns>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,11 +434,25 @@ private async Task<ResourceEvent> WaitForResourceCoreAsync(string resourceName,
/// <summary>
/// Attempts to retrieve the current state of a resource by resourceId.
/// </summary>
/// <param name="resourceId">The resource id.</param>
/// <remarks>
/// <para>
/// A resource id can be either the unique id of the resource or the displayed resource name.
/// </para>
/// <para>
/// Projects, executables and containers typically have a unique id that combines the display name and a unique suffix. For example, a resource named <c>cache</c> could have a resource id of <c>cache-abcdwxyz</c>.
/// This id is used to uniquely identify the resource in the app host.
/// </para>
/// <para>
/// 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 <c>cache</c> has multiple replicas, then specifing <c>cache</c> won't return a match.
/// </para>
/// </remarks>
/// <param name="resourceId">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).</param>
/// <param name="resourceEvent">When this method returns, contains the <see cref="ResourceEvent"/> for the specified resource id, if found; otherwise, <see langword="null"/>.</param>
/// <returns><see langword="true"/> if specified resource id was found; otherwise, <see langword="false"/>.</returns>
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)
Expand All @@ -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<string, ResourceNotificationState>? 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;
Copy link
Member

Choose a reason for hiding this comment

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

Replicas?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes

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;
}
Expand Down
60 changes: 60 additions & 0 deletions tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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<string>();

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()
Expand Down
Loading