diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index c665fb3e556..7aec2161ede 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -381,7 +381,7 @@ private static async Task WaitForResourceEventAsync( private WaitTargetResolutionResult ResolveWaitTarget(ResourceNotificationService notificationService, string requestedResourceName) { - var appModel = serviceProvider.GetService(); + var appModel = serviceProvider.GetRequiredService(); if (notificationService.TryGetCurrentState(requestedResourceName, out var resourceEvent)) { return WaitTargetResolutionResult.Success(new WaitResourceTarget( @@ -392,11 +392,6 @@ private WaitTargetResolutionResult ResolveWaitTarget(ResourceNotificationService // During startup the resource may not have published its first snapshot yet, so fall back to // the app model to resolve the requested logical name or resolved resource id. - if (appModel is null) - { - return WaitTargetResolutionResult.Success(new WaitResourceTarget(requestedResourceName, requestedResourceName, requestedResourceName)); - } - var matchingResource = appModel.Resources.SingleOrDefault(resource => string.Equals(resource.Name, requestedResourceName, StringComparisons.ResourceName)); if (matchingResource is not null) { @@ -426,13 +421,8 @@ private WaitTargetResolutionResult ResolveWaitTarget(ResourceNotificationService }; } - private static string ResolveDisplayName(DistributedApplicationModel? appModel, string requestedResourceName, string resolvedResourceName) + private static string ResolveDisplayName(DistributedApplicationModel appModel, string requestedResourceName, string resolvedResourceName) { - if (appModel is null) - { - return requestedResourceName; - } - var matchingResource = appModel.Resources .Select(resource => new { Resource = resource, ResolvedResourceNames = resource.GetResolvedResourceNames() }) .SingleOrDefault(match => match.ResolvedResourceNames.Any(resourceName => string.Equals(resourceName, resolvedResourceName, StringComparisons.ResourceName))); @@ -550,15 +540,10 @@ public async Task GetDashboardUrlsAsync(CancellationToken ca /// A list of resource snapshots. public async Task> GetResourceSnapshotsAsync(CancellationToken cancellationToken = default) { - var appModel = serviceProvider.GetService(); + var appModel = serviceProvider.GetRequiredService(); var notificationService = serviceProvider.GetRequiredService(); var results = new List(); - if (appModel is null) - { - return results; - } - // Get current state for each resource directly using TryGetCurrentState foreach (var resource in appModel.Resources) { @@ -763,42 +748,14 @@ public async IAsyncEnumerable GetResourceLogsAsync( var appModel = serviceProvider.GetRequiredService(); // Step 1: Calculate the resource names - var resourcesToLog = new List(); + var resourcesToLog = resourceName is not null + ? ResolveResourceIds(appModel, resourceName) + : ResolveAllResourceIds(appModel); - if (resourceName is not null) + if (resourceName is not null && resourcesToLog.Count == 0) { - // Match by logical name (stream all instances) or resolved instance name like - // "apiservice-abc123" (stream just that one) — MCP advertises the resolved form. - foreach (var resource in appModel.Resources) - { - var resolvedNames = resource.GetResolvedResourceNames(); - - if (string.Equals(resource.Name, resourceName, StringComparisons.ResourceName)) - { - resourcesToLog.AddRange(resolvedNames); - break; - } - - var matchedInstance = resolvedNames.FirstOrDefault(n => string.Equals(n, resourceName, StringComparisons.ResourceName)); - if (matchedInstance is not null) - { - resourcesToLog.Add(matchedInstance); - break; - } - } - - if (resourcesToLog.Count == 0) - { - logger.LogWarning("Resource '{ResourceName}' not found. No logs will be returned.", resourceName); - yield break; - } - } - else - { - foreach (var resource in appModel.Resources) - { - resourcesToLog.AddRange(resource.GetResolvedResourceNames()); - } + logger.LogWarning("Resource '{ResourceName}' not found. No logs will be returned.", resourceName); + yield break; } if (resourcesToLog.Count == 0) @@ -875,11 +832,7 @@ public async Task CallResourceMcpToolAsync( ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); ArgumentException.ThrowIfNullOrWhiteSpace(toolName); - var appModel = serviceProvider.GetService(); - if (appModel is null) - { - throw new InvalidOperationException("Application model not found."); - } + var appModel = serviceProvider.GetRequiredService(); var resource = appModel.Resources .OfType() @@ -1072,4 +1025,43 @@ private static object ConvertJsonNumber(JsonElement element) // Fall back to double for floating point return element.GetDouble(); } + + /// + /// Resolves a resource name (logical or resolved instance name) to the list of matching resource IDs. + /// Matches by logical name first (returning all instances), then falls back to matching a specific + /// resolved instance name like "apiservice-abc123". + /// + private static List ResolveResourceIds(DistributedApplicationModel appModel, string resourceName) + { + foreach (var resource in appModel.Resources) + { + var resolvedNames = resource.GetResolvedResourceNames(); + + if (string.Equals(resource.Name, resourceName, StringComparisons.ResourceName)) + { + return [.. resolvedNames]; + } + + var matchedInstance = resolvedNames.FirstOrDefault(n => string.Equals(n, resourceName, StringComparisons.ResourceName)); + if (matchedInstance is not null) + { + return [matchedInstance]; + } + } + + return []; + } + + /// + /// Returns the resolved resource IDs for all resources in the application model. + /// + private static List ResolveAllResourceIds(DistributedApplicationModel appModel) + { + var result = new List(); + foreach (var resource in appModel.Resources) + { + result.AddRange(resource.GetResolvedResourceNames()); + } + return result; + } } diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs index d13f18a147c..d2965fd77fb 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs @@ -1,7 +1,6 @@ // 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.Tests.Utils; using Aspire.Hosting.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; @@ -13,22 +12,6 @@ namespace Aspire.Hosting.Backchannel; [Trait("Partition", "4")] public class AuxiliaryBackchannelRpcTargetTests(ITestOutputHelper outputHelper) { - [Fact] - public async Task GetResourceSnapshotsAsync_ReturnsEmptyList_WhenAppModelIsNull() - { - var services = new ServiceCollection(); - services.AddSingleton(ResourceNotificationServiceTestHelpers.Create()); - var serviceProvider = services.BuildServiceProvider(); - - var target = new AuxiliaryBackchannelRpcTarget( - NullLogger.Instance, - serviceProvider); - - var result = await target.GetResourceSnapshotsAsync(); - - Assert.Empty(result); - } - [Fact] public async Task GetResourceSnapshotsAsync_EnumeratesResources() { @@ -44,23 +27,23 @@ public async Task GetResourceSnapshotsAsync_EnumeratesResources() ])); using var app = builder.Build(); - await app.StartAsync(); + await app.StartAsync().DefaultTimeout(); var notificationService = app.Services.GetRequiredService(); await notificationService.PublishUpdateAsync(resourceWithReplicas.Resource, "myresource-abc123", s => s with { State = new ResourceStateSnapshot("Running", KnownResourceStateStyles.Success) - }); + }).DefaultTimeout(); await notificationService.PublishUpdateAsync(resourceWithReplicas.Resource, "myresource-def456", s => s with { State = new ResourceStateSnapshot("Running", KnownResourceStateStyles.Success) - }); + }).DefaultTimeout(); var target = new AuxiliaryBackchannelRpcTarget( NullLogger.Instance, app.Services); - var result = await target.GetResourceSnapshotsAsync(); + var result = await target.GetResourceSnapshotsAsync().DefaultTimeout(); // Dashboard resource should now be included Assert.Contains(result, r => r.Name == KnownResourceNames.AspireDashboard); @@ -75,7 +58,7 @@ await notificationService.PublishUpdateAsync(resourceWithReplicas.Resource, "myr Assert.Contains(result, r => r.Name == "myresource-def456"); Assert.All(result.Where(r => r.Name.StartsWith("myresource-")), r => Assert.Equal("myresource", r.DisplayName)); - await app.StopAsync(); + await app.StopAsync().DefaultTimeout(); } [Fact] @@ -86,7 +69,7 @@ public async Task GetResourceSnapshotsAsync_MapsSnapshotData() var custom = builder.AddResource(new CustomResource("myresource")); using var app = builder.Build(); - await app.StartAsync(); + await app.StartAsync().DefaultTimeout(); var createdAt = DateTime.UtcNow.AddMinutes(-5); var startedAt = DateTime.UtcNow.AddMinutes(-4); @@ -127,13 +110,13 @@ await notificationService.PublishUpdateAsync(custom.Resource, s => s with new ResourcePropertySnapshot(CustomResourceKnownProperties.Source, "normal-value"), new ResourcePropertySnapshot("ConnectionString", "secret-value") { IsSensitive = true } ] - }); + }).DefaultTimeout(); var target = new AuxiliaryBackchannelRpcTarget( NullLogger.Instance, app.Services); - var result = await target.GetResourceSnapshotsAsync(); + var result = await target.GetResourceSnapshotsAsync().DefaultTimeout(); var snapshot = Assert.Single(result); @@ -194,7 +177,7 @@ await notificationService.PublishUpdateAsync(custom.Resource, s => s with Assert.True(snapshot.Properties.TryGetValue("ConnectionString", out var sensitiveValue)); Assert.Null(sensitiveValue); - await app.StopAsync(); + await app.StopAsync().DefaultTimeout(); } [Fact] @@ -208,7 +191,7 @@ public async Task WaitForResourceAsync_AcceptsResourceId() ])); using var app = builder.Build(); - await app.StartAsync(); + await app.StartAsync().DefaultTimeout(); var target = new AuxiliaryBackchannelRpcTarget( NullLogger.Instance, @@ -225,13 +208,13 @@ public async Task WaitForResourceAsync_AcceptsResourceId() await notificationService.PublishUpdateAsync(resourceWithReplicas.Resource, "myresource-abc123", s => s with { State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success) - }); + }).DefaultTimeout(); - var response = await waitTask.WaitAsync(TestConstants.DefaultTimeoutTimeSpan); + var response = await waitTask.DefaultTimeout(); Assert.True(response.Success); - await app.StopAsync(); + await app.StopAsync().DefaultTimeout(); } [Fact] @@ -245,7 +228,7 @@ public async Task WaitForResourceAsync_ResolvesLogicalResourceNameViaAppModel() ])); using var app = builder.Build(); - await app.StartAsync(); + await app.StartAsync().DefaultTimeout(); var target = new AuxiliaryBackchannelRpcTarget( NullLogger.Instance, @@ -262,13 +245,13 @@ public async Task WaitForResourceAsync_ResolvesLogicalResourceNameViaAppModel() await notificationService.PublishUpdateAsync(resourceWithReplicas.Resource, "myresource-abc123", s => s with { State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success) - }); + }).DefaultTimeout(); - var response = await waitTask.WaitAsync(TestConstants.DefaultTimeoutTimeSpan); + var response = await waitTask.DefaultTimeout(); Assert.True(response.Success); - await app.StopAsync(); + await app.StopAsync().DefaultTimeout(); } [Fact] @@ -282,7 +265,7 @@ public async Task WaitForResourceAsync_CancelledSingleInstanceResolvedName_UsesL ])); using var app = builder.Build(); - await app.StartAsync(); + await app.StartAsync().DefaultTimeout(); var target = new AuxiliaryBackchannelRpcTarget( NullLogger.Instance, @@ -300,7 +283,7 @@ public async Task WaitForResourceAsync_CancelledSingleInstanceResolvedName_UsesL Assert.Equal("Resource 'myresource' failed to become healthy before the operation was cancelled.", exception.Message); - await app.StopAsync(); + await app.StopAsync().DefaultTimeout(); } [Fact] @@ -315,7 +298,7 @@ public async Task WaitForResourceAsync_CancelledReplicaResolvedName_UsesReplicaD ])); using var app = builder.Build(); - await app.StartAsync(); + await app.StartAsync().DefaultTimeout(); var target = new AuxiliaryBackchannelRpcTarget( NullLogger.Instance, @@ -333,7 +316,7 @@ public async Task WaitForResourceAsync_CancelledReplicaResolvedName_UsesReplicaD Assert.Equal("Resource 'myresource-abc123' failed to become healthy before the operation was cancelled.", exception.Message); - await app.StopAsync(); + await app.StopAsync().DefaultTimeout(); } [Fact] @@ -348,7 +331,7 @@ public async Task WaitForResourceAsync_ReturnsAmbiguousErrorForReplicatedLogical ])); using var app = builder.Build(); - await app.StartAsync(); + await app.StartAsync().DefaultTimeout(); var target = new AuxiliaryBackchannelRpcTarget( NullLogger.Instance, @@ -359,13 +342,13 @@ public async Task WaitForResourceAsync_ReturnsAmbiguousErrorForReplicatedLogical ResourceName = "myresource", Status = "up", TimeoutSeconds = 5 - }); + }).DefaultTimeout(); Assert.False(response.Success); Assert.False(response.ResourceNotFound); Assert.Equal("Resource 'myresource' is ambiguous because it has multiple replicas. Specify the exact instance name.", response.ErrorMessage); - await app.StopAsync(); + await app.StopAsync().DefaultTimeout(); } private sealed class CustomResource(string name) : Resource(name) @@ -392,7 +375,7 @@ public async Task GetResourceLogsAsync_ReturnsLogs_ForSingleResource() var resourceLoggerService = app.Services.GetRequiredService(); resourceLoggerService.TimeProvider = new FixedTimeProvider(); - await app.StartAsync(); + await app.StartAsync().DefaultTimeout(); var logger = resourceLoggerService.GetLogger("myresource"); logger.LogInformation("Hello from myresource"); @@ -414,7 +397,7 @@ public async Task GetResourceLogsAsync_ReturnsLogs_ForSingleResource() Assert.Equal(0, log.LineNumber); Assert.False(log.IsError); - await app.StopAsync(); + await app.StopAsync().DefaultTimeout(); } [Fact] @@ -424,7 +407,7 @@ public async Task GetResourceLogsAsync_ReturnsEmpty_WhenResourceNotFound() builder.AddResource(new CustomResource("myresource")); using var app = builder.Build(); - await app.StartAsync(); + await app.StartAsync().DefaultTimeout(); var target = new AuxiliaryBackchannelRpcTarget( NullLogger.Instance, @@ -438,7 +421,7 @@ public async Task GetResourceLogsAsync_ReturnsEmpty_WhenResourceNotFound() Assert.Empty(logs); - await app.StopAsync(); + await app.StopAsync().DefaultTimeout(); } [Fact] @@ -455,7 +438,7 @@ public async Task GetResourceLogsAsync_ReturnsLogsFromAllResources_WhenNoResourc var resourceLoggerService = app.Services.GetRequiredService(); resourceLoggerService.TimeProvider = new FixedTimeProvider(); - await app.StartAsync(); + await app.StartAsync().DefaultTimeout(); var logger1 = resourceLoggerService.GetLogger("resource1"); logger1.LogInformation("Log from resource1"); @@ -483,7 +466,7 @@ public async Task GetResourceLogsAsync_ReturnsLogsFromAllResources_WhenNoResourc var log2 = Assert.Single(logs, l => l.ResourceName == "resource2"); Assert.Equal($"{TestTimestamp} Log from resource2", log2.Content); - await app.StopAsync(); + await app.StopAsync().DefaultTimeout(); } [Fact] @@ -497,12 +480,17 @@ public async Task GetResourceLogsAsync_ReturnsLogsFromReplicas() new DcpInstance("myresource-def456", "def456", 1) ])); + var otherResource = builder.AddResource(new CustomResource("otherresource")); + otherResource.WithAnnotation(new DcpInstancesAnnotation([ + new DcpInstance("otherresource-xyz789", "xyz789", 0) + ])); + using var app = builder.Build(); var resourceLoggerService = app.Services.GetRequiredService(); resourceLoggerService.TimeProvider = new FixedTimeProvider(); - await app.StartAsync(); + await app.StartAsync().DefaultTimeout(); var logger1 = resourceLoggerService.GetLogger("myresource-abc123"); logger1.LogInformation("Log from replica 1"); @@ -512,6 +500,10 @@ public async Task GetResourceLogsAsync_ReturnsLogsFromReplicas() logger2.LogInformation("Log from replica 2"); resourceLoggerService.Complete("myresource-def456"); + var otherLogger = resourceLoggerService.GetLogger("otherresource-xyz789"); + otherLogger.LogInformation("Log from other resource"); + resourceLoggerService.Complete("otherresource-xyz789"); + var target = new AuxiliaryBackchannelRpcTarget( NullLogger.Instance, app.Services); @@ -530,7 +522,9 @@ public async Task GetResourceLogsAsync_ReturnsLogsFromReplicas() var replica2 = Assert.Single(logs, l => l.ResourceName == "myresource-def456"); Assert.Equal($"{TestTimestamp} Log from replica 2", replica2.Content); - await app.StopAsync(); + Assert.DoesNotContain(logs, l => l.ResourceName == "otherresource-xyz789"); + + await app.StopAsync().DefaultTimeout(); } [Fact] @@ -549,7 +543,7 @@ public async Task GetResourceLogsAsync_ReturnsLogsForSingleReplica_WhenResolvedI var resourceLoggerService = app.Services.GetRequiredService(); resourceLoggerService.TimeProvider = new FixedTimeProvider(); - await app.StartAsync(); + await app.StartAsync().DefaultTimeout(); var logger1 = resourceLoggerService.GetLogger("myresource-abc123"); logger1.LogInformation("Log from replica 1"); @@ -573,7 +567,15 @@ public async Task GetResourceLogsAsync_ReturnsLogsForSingleReplica_WhenResolvedI Assert.Equal("myresource-def456", log.ResourceName); Assert.Equal($"{TestTimestamp} Log from replica 2", log.Content); - await app.StopAsync(); + var badLogs = new List(); + await foreach (var logLine in target.GetResourceLogsAsync("myresource-nonexistent", follow: false)) + { + badLogs.Add(logLine); + } + + Assert.Empty(badLogs); + + await app.StopAsync().DefaultTimeout(); } [Fact] @@ -587,7 +589,7 @@ public async Task GetResourceLogsAsync_FollowMode_StreamsLogs() var resourceLoggerService = app.Services.GetRequiredService(); resourceLoggerService.TimeProvider = new FixedTimeProvider(); - await app.StartAsync(); + await app.StartAsync().DefaultTimeout(); var target = new AuxiliaryBackchannelRpcTarget( NullLogger.Instance, @@ -613,7 +615,7 @@ public async Task GetResourceLogsAsync_FollowMode_StreamsLogs() logger.LogInformation("First log"); logger.LogInformation("Second log"); - await collectTask.WaitAsync(TimeSpan.FromSeconds(10)); + await collectTask.DefaultTimeout(); Assert.Equal(2, logs.Count); @@ -623,7 +625,7 @@ public async Task GetResourceLogsAsync_FollowMode_StreamsLogs() Assert.Equal("myresource", logs[1].ResourceName); Assert.Equal($"{TestTimestamp} Second log", logs[1].Content); - await app.StopAsync(); + await app.StopAsync().DefaultTimeout(); } [Fact] @@ -649,7 +651,7 @@ await notificationService.PublishUpdateAsync(dashboard, snapshot => snapshot wit { State = KnownResourceStates.Running, ResourceReadyEvent = new EventSnapshot(Task.CompletedTask) - }); + }).DefaultTimeout(); var target = new AuxiliaryBackchannelRpcTarget( NullLogger.Instance, @@ -661,4 +663,61 @@ await notificationService.PublishUpdateAsync(dashboard, snapshot => snapshot wit Assert.Equal("http://localhost:18888", result.BaseUrlWithLoginToken); Assert.Null(result.CodespacesUrlWithLoginToken); } + + [Fact] + public async Task WaitForResourceAsync_ReturnsNotFound_WhenResourceDoesNotExist() + { + using var builder = TestDistributedApplicationBuilder.Create(outputHelper); + builder.AddResource(new CustomResource("myresource")); + + using var app = builder.Build(); + await app.StartAsync().DefaultTimeout(); + + var target = new AuxiliaryBackchannelRpcTarget( + NullLogger.Instance, + app.Services); + + var result = await target.WaitForResourceAsync(new WaitForResourceRequest + { + ResourceName = "nonexistent", + Status = "up", + TimeoutSeconds = 10 + }).DefaultTimeout(); + + Assert.False(result.Success); + Assert.True(result.ResourceNotFound); + + await app.StopAsync().DefaultTimeout(); + } + + [Fact] + public async Task WaitForResourceAsync_ReturnsNotFound_ForBadInstanceName() + { + using var builder = TestDistributedApplicationBuilder.Create(outputHelper); + + var resourceWithReplicas = builder.AddResource(new CustomResource("myresource")); + resourceWithReplicas.WithAnnotation(new DcpInstancesAnnotation([ + new DcpInstance("myresource-abc123", "abc123", 0), + new DcpInstance("myresource-def456", "def456", 1) + ])); + + using var app = builder.Build(); + await app.StartAsync().DefaultTimeout(); + + var target = new AuxiliaryBackchannelRpcTarget( + NullLogger.Instance, + app.Services); + + var result = await target.WaitForResourceAsync(new WaitForResourceRequest + { + ResourceName = "myresource-nonexistent", + Status = "up", + TimeoutSeconds = 10 + }).DefaultTimeout(); + + Assert.False(result.Success); + Assert.True(result.ResourceNotFound); + + await app.StopAsync().DefaultTimeout(); + } }