diff --git a/src/Aspire.Hosting.DevTunnels/DevTunnelHealthCheck.cs b/src/Aspire.Hosting.DevTunnels/DevTunnelHealthCheck.cs index 438c9dbf185..ce3476a40c0 100644 --- a/src/Aspire.Hosting.DevTunnels/DevTunnelHealthCheck.cs +++ b/src/Aspire.Hosting.DevTunnels/DevTunnelHealthCheck.cs @@ -42,15 +42,21 @@ public async Task CheckHealthAsync(HealthCheckContext context } } + // `devtunnel show ` resolves a bare ID and returns its cluster-qualified ID, but + // `devtunnel access list --port-number ` requires the qualified form. + // Remove this workaround when the CLI resolves bare IDs for per-port access queries. + // See https://github.com/microsoft/aspire/issues/18790. + var resolvedTunnelId = tunnelStatus.TunnelId; + // Get tunnel and port access status - var tunnelAccessStatus = await _devTunnelClient.GetAccessAsync(_tunnelResource.ResolvedTunnelId, portNumber: null, logger, cancellationToken).ConfigureAwait(false); + var tunnelAccessStatus = await _devTunnelClient.GetAccessAsync(resolvedTunnelId, portNumber: null, logger, cancellationToken).ConfigureAwait(false); _tunnelResource.LastKnownAccessStatus = tunnelAccessStatus; // Get access status for each port foreach (var portResource in _tunnelResource.Ports) { var tunnelPort = await portResource.GetTunnelPortAsync(cancellationToken).ConfigureAwait(false); - var portAccessStatus = await _devTunnelClient.GetAccessAsync(_tunnelResource.ResolvedTunnelId, tunnelPort, logger, cancellationToken).ConfigureAwait(false); + var portAccessStatus = await _devTunnelClient.GetAccessAsync(resolvedTunnelId, tunnelPort, logger, cancellationToken).ConfigureAwait(false); portResource.LastKnownAccessStatus = portAccessStatus; } diff --git a/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs b/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs index 61015ab3a1a..b854cc42992 100644 --- a/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs @@ -143,10 +143,15 @@ public static IResourceBuilder AddDevTunnel( await devTunnelEnvironmentManager.EnsureUserLoggedInAsync(ct).ConfigureAwait(false); // Create the dev tunnel + string resolvedTunnelId; try { logger.LogInformation("Creating dev tunnel '{TunnelId}'", tunnelResource.TunnelId); var tunnelStatus = await devTunnelClient.CreateTunnelAsync(tunnelResource.TunnelId, tunnelResource.Options, logger, ct).ConfigureAwait(false); + // The CLI resolves a bare ID and returns its cluster-qualified ID. Use that ID for + // port operations because bare IDs may not resolve tunnels across clusters. + // See https://github.com/microsoft/aspire/issues/18790. + resolvedTunnelId = tunnelStatus.TunnelId; logger.LogDebug("Dev tunnel '{TunnelId}' created", tunnelResource.TunnelId); } catch (Exception ex) @@ -174,13 +179,13 @@ public static IResourceBuilder AddDevTunnel( async Task DeleteUnmodeledPortsAsync() { - var existingPorts = await devTunnelClient.GetPortListAsync(tunnelResource.ResolvedTunnelId, logger, ct).ConfigureAwait(false); + var existingPorts = await devTunnelClient.GetPortListAsync(resolvedTunnelId, logger, ct).ConfigureAwait(false); var modeledPortNumbers = (await Task.WhenAll(tunnelResource.Ports.Select(p => p.GetTunnelPortAsync(ct).AsTask())).ConfigureAwait(false)).ToHashSet(); var unmodeledPorts = existingPorts.Ports.Where(p => !modeledPortNumbers.Contains(p.PortNumber)).ToList(); if (unmodeledPorts.Count > 0) { logger.LogInformation("Deleting {Count} unmodeled ports from dev tunnel '{TunnelId}': {Ports}", unmodeledPorts.Count, tunnelResource.TunnelId, string.Join(", ", unmodeledPorts.Select(p => p.PortNumber))); - await Task.WhenAll(unmodeledPorts.Select(p => devTunnelClient.DeletePortAsync(tunnelResource.ResolvedTunnelId, p.PortNumber, logger, ct))).ConfigureAwait(false); + await Task.WhenAll(unmodeledPorts.Select(p => devTunnelClient.DeletePortAsync(resolvedTunnelId, p.PortNumber, logger, ct))).ConfigureAwait(false); } } @@ -200,7 +205,7 @@ await notifications.PublishUpdateAsync(portResource, snapshot => snapshot with try { _ = await devTunnelClient.CreatePortAsync( - portResource.DevTunnel.ResolvedTunnelId, + resolvedTunnelId, tunnelPort, portResource.Options, portLogger, diff --git a/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelResourceBuilderExtensionsTests.cs b/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelResourceBuilderExtensionsTests.cs index 54c4f8ae5d3..9603bca5aeb 100644 --- a/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelResourceBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelResourceBuilderExtensionsTests.cs @@ -192,6 +192,7 @@ public async Task OnBeforeResourceStarted_WithRegion_UsesResolvedTunnelIdForPort { var client = new TestDevTunnelClient { + CreatedTunnelId = "mytunnel.eun1", PortList = new() { Ports = [ @@ -228,6 +229,102 @@ public async Task OnBeforeResourceStarted_WithRegion_UsesResolvedTunnelIdForPort Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.DeletePortAsync) && call.TunnelId == "mytunnel.eun1" && call.PortNumber == 6000); } + [Fact] + public async Task OnBeforeResourceStarted_WithAutoSelectedRegion_UsesCreatedTunnelIdForPortOperations() + { + var client = new TestDevTunnelClient + { + CreatedTunnelId = "mytunnel.eun1", + PortList = new() + { + Ports = [ + new(5001, "https"), + new(6000, "https") + ] + } + }; + + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Services.AddSingleton(client); + builder.Services.AddSingleton(); + + var target = builder.AddProject("target") + .WithHttpEndpoint(port: 5000, targetPort: 5001, name: "http"); + var tunnel = builder.AddDevTunnel("tunnel", "mytunnel") + .WithReference(target); + var tunnelPort = Assert.Single(tunnel.Resource.Ports); + tunnelPort.TargetEndpoint.EndpointAnnotation.AllocatedEndpoint = new( + tunnelPort.TargetEndpoint.EndpointAnnotation, + "localhost", + 5000); + + using var app = builder.Build(); + + await builder.Eventing.PublishAsync(new BeforeResourceStartedEvent(tunnel.Resource, app.Services)).DefaultTimeout(); + + var calls = client.Calls.ToArray(); + Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.CreateTunnelAsync) && call.TunnelId == "mytunnel"); + Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.GetPortListAsync) && call.TunnelId == "mytunnel.eun1"); + Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.CreatePortAsync) && call.TunnelId == "mytunnel.eun1" && call.PortNumber == 5001); + Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.DeletePortAsync) && call.TunnelId == "mytunnel.eun1" && call.PortNumber == 6000); + } + + [Fact] + public async Task DevTunnelHealthCheck_WithAutoSelectedRegion_UsesReturnedTunnelIdForAccessOperations() + { + var client = new TestDevTunnelClient + { + TunnelStatus = new("mytunnel.eun1", HostConnections: 1, ClientConnections: 0, Description: "", Labels: []) + { + Ports = [ + new(5001, "https") + { + PortUri = new("https://mytunnel-5001.eun1.devtunnels.ms") + } + ] + } + }; + + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Services.AddSingleton(client); + + var target = builder.AddProject("target") + .WithHttpEndpoint(port: 5000, targetPort: 5001, name: "http"); + var tunnel = builder.AddDevTunnel("tunnel", "mytunnel") + .WithReference(target); + + using var app = builder.Build(); + var healthCheck = new DevTunnelHealthCheck( + client, + app.Services.GetRequiredService(), + tunnel.Resource, + app.Services.GetRequiredService>()); + + var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()).DefaultTimeout(); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Collection( + client.Calls, + call => + { + Assert.Equal(nameof(IDevTunnelClient.GetTunnelAsync), call.Method); + Assert.Equal("mytunnel", call.TunnelId); + Assert.Null(call.PortNumber); + }, + call => + { + Assert.Equal(nameof(IDevTunnelClient.GetAccessAsync), call.Method); + Assert.Equal("mytunnel.eun1", call.TunnelId); + Assert.Null(call.PortNumber); + }, + call => + { + Assert.Equal(nameof(IDevTunnelClient.GetAccessAsync), call.Method); + Assert.Equal("mytunnel.eun1", call.TunnelId); + Assert.Equal(5001, call.PortNumber); + }); + } + [Fact] public async Task DevTunnelHealthCheck_WithRegion_UsesResolvedTunnelIdForTunnelAndAccessOperations() { diff --git a/tests/Aspire.Hosting.DevTunnels.Tests/TestDevTunnelClient.cs b/tests/Aspire.Hosting.DevTunnels.Tests/TestDevTunnelClient.cs index 2b85a788e88..22241bc62e2 100644 --- a/tests/Aspire.Hosting.DevTunnels.Tests/TestDevTunnelClient.cs +++ b/tests/Aspire.Hosting.DevTunnels.Tests/TestDevTunnelClient.cs @@ -19,6 +19,8 @@ internal sealed class TestDevTunnelClient(Version? cliVersion = null) : IDevTunn public DevTunnelStatus TunnelStatus { get; set; } = new("test-tunnel", HostConnections: 1, ClientConnections: 0, Description: "", Labels: []); + public string? CreatedTunnelId { get; set; } + public DevTunnelAccessStatus AccessStatus { get; set; } = new(); public Task GetVersionAsync(ILogger? logger = null, CancellationToken cancellationToken = default) @@ -42,7 +44,7 @@ public Task UserLoginAsync(LoginProvider provider, ILogger? log public Task CreateTunnelAsync(string tunnelId, DevTunnelOptions options, ILogger? logger = null, CancellationToken cancellationToken = default) { Calls.Enqueue(new(nameof(CreateTunnelAsync), tunnelId)); - return Task.FromResult(new DevTunnelStatus(tunnelId, HostConnections: 1, ClientConnections: 0, Description: "", Labels: [])); + return Task.FromResult(new DevTunnelStatus(CreatedTunnelId ?? tunnelId, HostConnections: 1, ClientConnections: 0, Description: "", Labels: [])); } public Task GetPortListAsync(string tunnelId, ILogger? logger = null, CancellationToken cancellationToken = default)