diff --git a/src/Aspire.Hosting.DevTunnels/DevTunnelCli.cs b/src/Aspire.Hosting.DevTunnels/DevTunnelCli.cs index 717c3fafe39..d810806eb29 100644 --- a/src/Aspire.Hosting.DevTunnels/DevTunnelCli.cs +++ b/src/Aspire.Hosting.DevTunnels/DevTunnelCli.cs @@ -60,6 +60,7 @@ public Task CreateTunnelAsync( return RunAsync(new ArgsBuilder(["create"]) .AddIfNotNull(tunnelId) .AddIfNotNull("--description", options.Description) + .AddIfNotNull("--service-uri", GetServiceUri(options)) .AddIfTrue("--allow-anonymous", options.AllowAnonymous) .AddValues("--labels", options.Labels) .Add("--json") @@ -391,6 +392,16 @@ private static async Task PumpAsync(StreamReader reader, Action onLine, } } } + + private static string? GetServiceUri(DevTunnelOptions options) + { + if (options.Region is null) + { + return null; + } + + return $"https://{options.RegionCode}.rel.tunnels.api.visualstudio.com"; + } } internal sealed class ArgsBuilder(IEnumerable initialArgs) diff --git a/src/Aspire.Hosting.DevTunnels/DevTunnelCliClient.cs b/src/Aspire.Hosting.DevTunnels/DevTunnelCliClient.cs index aaaaa07396a..6ee66a8c490 100644 --- a/src/Aspire.Hosting.DevTunnels/DevTunnelCliClient.cs +++ b/src/Aspire.Hosting.DevTunnels/DevTunnelCliClient.cs @@ -54,6 +54,7 @@ public async Task CreateTunnelAsync(string tunnelId, DevTunnelO var attempts = 0; var exitCode = 0; string? error = null; + string resolvedTunnelId = options.Region is not null ? $"{tunnelId}.{options.RegionCode}" : tunnelId; while (attempts < _maxCliAttempts) { @@ -62,8 +63,7 @@ public async Task CreateTunnelAsync(string tunnelId, DevTunnelO { logger?.LogTrace("Attempt {Attempt} of {MaxAttempts} to create dev tunnel '{TunnelId}'", attempts, _maxCliAttempts, tunnelId); } - (var tunnel, exitCode, error) = await CallCliAsJsonAsync( - (stdout, stderr, log, ct) => _cli.CreateTunnelAsync(tunnelId, options, stdout, stderr, log, ct), + (var tunnel, exitCode, error) = await CallCliAsJsonAsync((stdout, stderr, log, ct) => _cli.CreateTunnelAsync(tunnelId, options, stdout, stderr, log, ct), "tunnel", logger, cancellationToken).ConfigureAwait(false); @@ -78,31 +78,31 @@ public async Task CreateTunnelAsync(string tunnelId, DevTunnelO // Update the tunnel as it already exists logger?.LogTrace("Dev tunnel '{TunnelId}' already exists, will update it instead.", tunnelId); (tunnel, exitCode, error) = await CallCliAsJsonAsync( - (stdout, stderr, log, ct) => _cli.UpdateTunnelAsync(tunnelId, options, stdout, stderr, log, ct), + (stdout, stderr, log, ct) => _cli.UpdateTunnelAsync(resolvedTunnelId, options, stdout, stderr, log, ct), logger, cancellationToken).ConfigureAwait(false); if (exitCode == 0 && tunnel is not null) { - logger?.LogTrace("Dev tunnel '{TunnelId}' updated successfully.", tunnelId); + logger?.LogTrace("Dev tunnel '{TunnelId}' updated successfully.", resolvedTunnelId); // Ensure tunnel access controls are set as specified in options by resetting existing policies first. // Ports get deleted and recreated separately, so we only need to reset access on the tunnel itself here. - logger?.LogTrace("Clearing access policies for dev tunnel '{TunnelId}'.", tunnelId); + logger?.LogTrace("Clearing access policies for dev tunnel '{TunnelId}'.", resolvedTunnelId); (var accessStatus, exitCode, error) = await CallCliAsJsonAsync( - (stdout, stderr, log, ct) => _cli.ResetAccessAsync(tunnelId, portNumber: null, stdout, stderr, log, ct), + (stdout, stderr, log, ct) => _cli.ResetAccessAsync(resolvedTunnelId, portNumber: null, stdout, stderr, log, ct), logger, cancellationToken).ConfigureAwait(false); if (exitCode == 0 && accessStatus is { AccessControlEntries: [] }) { - logger?.LogTrace("Dev tunnel '{TunnelId}' access policies cleared successfully.", tunnelId); + logger?.LogTrace("Dev tunnel '{TunnelId}' access policies cleared successfully.", resolvedTunnelId); if (options.AllowAnonymous) { // Set anonymous access as specified - logger?.LogTrace("Allowing anonymous access for dev tunnel '{TunnelId}'.", tunnelId); + logger?.LogTrace("Allowing anonymous access for dev tunnel '{TunnelId}'.", resolvedTunnelId); (accessStatus, exitCode, error) = await CallCliAsJsonAsync( - (stdout, stderr, log, ct) => _cli.CreateAccessAsync(tunnelId, portNumber: null, anonymous: true, deny: false, stdout, stderr, log, ct), + (stdout, stderr, log, ct) => _cli.CreateAccessAsync(resolvedTunnelId, portNumber: null, anonymous: true, deny: false, stdout, stderr, log, ct), logger, cancellationToken).ConfigureAwait(false); if (exitCode == 0 && accessStatus is not null) { - logger?.LogTrace("Dev tunnel '{TunnelId}' anonymous access set successfully.", tunnelId); + logger?.LogTrace("Dev tunnel '{TunnelId}' anonymous access set successfully.", resolvedTunnelId); } } if (exitCode == 0 && accessStatus is not null) @@ -143,7 +143,7 @@ public async Task GetPortListAsync(string tunnelId, ILogger? return ports ?? throw new DistributedApplicationException($"Failed to get port list for dev tunnel '{tunnelId}'. Exit code {exitCode}: {error}"); } - public async Task CreatePortAsync(string tunnelId, int portNumber, DevTunnelPortOptions options, ILogger? logger = default, CancellationToken cancellationToken = default) + public async Task CreatePortAsync(string tunnelId, int portNumber, DevTunnelPortOptions portOptions, ILogger? logger = default, CancellationToken cancellationToken = default) { var attempts = 0; var exitCode = 0; @@ -152,24 +152,24 @@ public async Task CreatePortAsync(string tunnelId, int port while (attempts < _maxCliAttempts) { - logger?.LogTrace("Creating port '{PortNumber}' on dev tunnel '{TunnelId}' with options: {Options}", portNumber, tunnelId, options.ToLoggerString()); + logger?.LogTrace("Creating port '{PortNumber}' on dev tunnel '{TunnelId}' with options: {Options}", portNumber, tunnelId, portOptions.ToLoggerString()); if (attempts++ > 1) { logger?.LogTrace("Attempt {Attempt} of {MaxAttempts} to create port '{PortNumber}' on dev tunnel '{TunnelId}'", attempts, _maxCliAttempts, portNumber, tunnelId); } (port, exitCode, error) = await CallCliAsJsonAsync( - (outWriter, errWriter, log, ct) => _cli.CreatePortAsync(tunnelId, portNumber, options, outWriter, errWriter, log, ct), + (outWriter, errWriter, log, ct) => _cli.CreatePortAsync(tunnelId, portNumber, portOptions, outWriter, errWriter, log, ct), logger, cancellationToken).ConfigureAwait(false); if (exitCode == 0 && port is not null) { - if (options.AllowAnonymous.HasValue) + if (portOptions.AllowAnonymous.HasValue) { // AllowAnonymous=true: anonymous=true, deny=false // AllowAnonymous=false: anonymous=true, deny=true var anonymous = true; - var deny = !options.AllowAnonymous.Value; + var deny = !portOptions.AllowAnonymous.Value; if (deny) { logger?.LogTrace("Denying anonymous access for port '{PortNumber}' on dev tunnel '{TunnelId}'.", portNumber, tunnelId); diff --git a/src/Aspire.Hosting.DevTunnels/DevTunnelHealthCheck.cs b/src/Aspire.Hosting.DevTunnels/DevTunnelHealthCheck.cs index f5fec38c837..126bdc7dc6e 100644 --- a/src/Aspire.Hosting.DevTunnels/DevTunnelHealthCheck.cs +++ b/src/Aspire.Hosting.DevTunnels/DevTunnelHealthCheck.cs @@ -24,7 +24,7 @@ public async Task CheckHealthAsync(HealthCheckContext context { try { - var tunnelStatus = await _devTunnelClient.GetTunnelAsync(_tunnelResource.TunnelId, logger, cancellationToken).ConfigureAwait(false); + var tunnelStatus = await _devTunnelClient.GetTunnelAsync(_tunnelResource.ResolvedTunnelId, logger, cancellationToken).ConfigureAwait(false); tunnelResource.LastKnownStatus = tunnelStatus; if (tunnelStatus.HostConnections == 0) { @@ -44,14 +44,14 @@ public async Task CheckHealthAsync(HealthCheckContext context } // Get tunnel and port access status - var tunnelAccessStatus = await _devTunnelClient.GetAccessAsync(_tunnelResource.TunnelId, portNumber: null, logger, cancellationToken).ConfigureAwait(false); + var tunnelAccessStatus = await _devTunnelClient.GetAccessAsync(_tunnelResource.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.TunnelId, tunnelPort, logger, cancellationToken).ConfigureAwait(false); + var portAccessStatus = await _devTunnelClient.GetAccessAsync(_tunnelResource.ResolvedTunnelId, tunnelPort, logger, cancellationToken).ConfigureAwait(false); portResource.LastKnownAccessStatus = portAccessStatus; } diff --git a/src/Aspire.Hosting.DevTunnels/DevTunnelOptions.cs b/src/Aspire.Hosting.DevTunnels/DevTunnelOptions.cs index 5276a027d92..e7aaa53f066 100644 --- a/src/Aspire.Hosting.DevTunnels/DevTunnelOptions.cs +++ b/src/Aspire.Hosting.DevTunnels/DevTunnelOptions.cs @@ -23,7 +23,36 @@ public sealed class DevTunnelOptions /// public List? Labels { get; set; } - internal string ToLoggerString() => $"{{ Description={Description}, AllowAnonymous={AllowAnonymous}, Labels=[{string.Join(", ", Labels ?? [])}] }}"; + /// + /// Optional region to create the dev tunnel in. + /// If not specified, the region will be selected automatically based on the ping. + /// + /// + /// Set this value, for example to DevTunnelRegion.NorthEurope, when an existing tunnel must be reused in a specific service region. + /// + public DevTunnelRegion? Region { get; set; } + + internal string RegionCode => + Region switch + { + DevTunnelRegion.WestEurope => "euw", + DevTunnelRegion.UkSouth => "uks1", + DevTunnelRegion.NorthEurope => "eun1", + DevTunnelRegion.EastUs => "use", + DevTunnelRegion.EastUs2 => "use2", + DevTunnelRegion.WestUs2 => "usw2", + DevTunnelRegion.WestUs3 => "usw3", + DevTunnelRegion.CentralIndia => "inc1", + DevTunnelRegion.SouthEastAsia => "asse", + DevTunnelRegion.BrazilSouth => "brs", + DevTunnelRegion.AustraliaCentral => "auc1", + DevTunnelRegion.AustraliaEast => "aue", + DevTunnelRegion.JapanEast => "jpe1", + null => string.Empty, + _ => throw new ArgumentException("Invalid region specified", nameof(Region)), + }; + + internal string ToLoggerString() => $"{{ Description={Description}, AllowAnonymous={AllowAnonymous}, Labels=[{string.Join(", ", Labels ?? [])}], Region={Region} }}"; } /// @@ -53,3 +82,74 @@ public sealed class DevTunnelPortOptions internal string ToLoggerString() => $"{{ Description={Description}, AllowAnonymous={AllowAnonymous}, Protocol={Protocol}, Labels=[{string.Join(", ", Labels ?? [])}] }}"; } + +/// +/// Region options for dev tunnel creation. +/// +public enum DevTunnelRegion : byte +{ + /// + /// West Europe region. + /// + WestEurope, + + /// + /// UK South region. + /// + UkSouth, + + /// + /// North Europe region. + /// + NorthEurope, + + /// + /// East US region. + /// + EastUs, + + /// + /// East US 2 region. + /// + EastUs2, + + /// + /// Central India region. + /// + CentralIndia, + + /// + /// West US 3 region. + /// + WestUs3, + + /// + /// West US 2 region. + /// + WestUs2, + + /// + /// Southeast Asia region. + /// + SouthEastAsia, + + /// + /// Brazil South region. + /// + BrazilSouth, + + /// + /// Australia Central region. + /// + AustraliaCentral, + + /// + /// Australia East region. + /// + AustraliaEast, + + /// + /// Japan East region. + /// + JapanEast +} diff --git a/src/Aspire.Hosting.DevTunnels/DevTunnelResource.cs b/src/Aspire.Hosting.DevTunnels/DevTunnelResource.cs index ce72eaa4e24..914945900a6 100644 --- a/src/Aspire.Hosting.DevTunnels/DevTunnelResource.cs +++ b/src/Aspire.Hosting.DevTunnels/DevTunnelResource.cs @@ -29,6 +29,11 @@ public sealed class DevTunnelResource(string name, string tunnelId, string comma /// public string TunnelId { get; init; } = tunnelId; + /// + /// Gets the fully qualified tunnel ID including the region suffix if a region is specified. + /// + internal string ResolvedTunnelId => Options.Region is not null ? $"{TunnelId}.{Options.RegionCode}" : TunnelId; + internal List Ports { get; } = []; internal DevTunnelStatus? LastKnownStatus { get; set; } diff --git a/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs b/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs index 52dd13d54d6..bd6d0569687 100644 --- a/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs @@ -103,7 +103,7 @@ public static IResourceBuilder AddDevTunnel( #pragma warning restore ASPIREINTERACTION001 var rb = builder.AddResource(tunnelResource) - .WithArgs("host", tunnelId, "--nologo") + .WithArgs("host", tunnelResource.ResolvedTunnelId, "--nologo") .WithIconName("CloudBidirectional") .WithEnvironment("TUNNEL_SERVICE_USER_AGENT", s_aspireUserAgent) .WithInitialState(new() @@ -176,13 +176,13 @@ public static IResourceBuilder AddDevTunnel( async Task DeleteUnmodeledPortsAsync() { - var existingPorts = await devTunnelClient.GetPortListAsync(tunnelResource.TunnelId, logger, ct).ConfigureAwait(false); + var existingPorts = await devTunnelClient.GetPortListAsync(tunnelResource.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.TunnelId, p.PortNumber, logger, ct))).ConfigureAwait(false); + await Task.WhenAll(unmodeledPorts.Select(p => devTunnelClient.DeletePortAsync(tunnelResource.ResolvedTunnelId, p.PortNumber, logger, ct))).ConfigureAwait(false); } } @@ -202,7 +202,7 @@ await notifications.PublishUpdateAsync(portResource, snapshot => snapshot with try { _ = await devTunnelClient.CreatePortAsync( - portResource.DevTunnel.TunnelId, + portResource.DevTunnel.ResolvedTunnelId, tunnelPort, portResource.Options, portLogger, diff --git a/src/Aspire.Hosting.DevTunnels/README.md b/src/Aspire.Hosting.DevTunnels/README.md index 2ebb3c9eaef..3bb94fbc99e 100644 --- a/src/Aspire.Hosting.DevTunnels/README.md +++ b/src/Aspire.Hosting.DevTunnels/README.md @@ -93,6 +93,25 @@ var tunnel = builder.AddDevTunnel( .WithReference(api); ``` +### Setting devtunnel region + +When creating a dev tunnel, you can optionally specify the Azure region where the tunnel will be hosted. +If not set, when attempting to connect to an existing dev tunnel, it is possible based on ping a different region is chosen. This will create a new dev tunnel in that region, +which may be undesired if testing registered webhooks or a similar scenario. + +To prevent this behaviour, it is recommended to explicitly set the desired region. + +```csharp +var options = new DevTunnelOptions +{ + Region = DevTunnelRegion.NorthEurope +}; + +var tunnel = builder.AddDevTunnel(name: "devtunnel", options: options) + .WithReference(api); + +``` + ### Multiple tunnels for different audiences ```csharp diff --git a/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelCliVersionValidationTests.cs b/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelCliVersionValidationTests.cs index a1271b04b2c..18931f8fe0f 100644 --- a/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelCliVersionValidationTests.cs +++ b/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelCliVersionValidationTests.cs @@ -5,7 +5,6 @@ using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Aspire.Hosting.DevTunnels.Tests; @@ -46,48 +45,4 @@ public async Task ValidateDevTunnelCliVersionAsync_ReturnsInvalidForUnsupportedV } } - private sealed class TestDevTunnelClient(Version cliVersion) : IDevTunnelClient - { - public Task GetVersionAsync(ILogger? logger = null, CancellationToken cancellationToken = default) => Task.FromResult(cliVersion); - - public Task CreatePortAsync(string tunnelId, int portNumber, DevTunnelPortOptions options, ILogger? logger = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task CreateTunnelAsync(string tunnelId, DevTunnelOptions options, ILogger? logger = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task GetAccessAsync(string tunnelId, int? portNumber = null, ILogger? logger = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task GetTunnelAsync(string tunnelId, ILogger? logger = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task GetUserLoginStatusAsync(ILogger? logger = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task UserLoginAsync(LoginProvider provider, ILogger? logger = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task GetPortListAsync(string tunnelId, ILogger? logger = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task DeletePortAsync(string tunnelId, int portNumber, ILogger? logger = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - } } diff --git a/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelResourceBuilderExtensionsTests.cs b/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelResourceBuilderExtensionsTests.cs index 0066b97b898..92216c69ffb 100644 --- a/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelResourceBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelResourceBuilderExtensionsTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIRECOMMAND001 // Required command validation APIs are experimental. #pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental. using Aspire.Hosting.ApplicationModel; @@ -8,6 +9,8 @@ using Aspire.Hosting.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting.DevTunnels.Tests; @@ -167,6 +170,106 @@ public async Task WithReference_UsesAllocatedPortForDevTunnelPortWhenTargetPortI Assert.Equal(5000, await tunnelPort.GetTunnelPortAsync()); } + [Fact] + public async Task AddDevTunnel_WithRegion_UsesResolvedTunnelIdForExecutableArgs() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var tunnel = builder.AddDevTunnel("tunnel", "mytunnel", new DevTunnelOptions + { + Region = DevTunnelRegion.NorthEurope + }); + +#pragma warning disable CS0618 // Type or member is obsolete + var args = await tunnel.Resource.GetArgumentValuesAsync().DefaultTimeout(); +#pragma warning restore CS0618 // Type or member is obsolete + + Assert.Equal(["host", "mytunnel.eun1", "--nologo"], args); + } + + [Fact] + public async Task OnBeforeResourceStarted_WithRegion_UsesResolvedTunnelIdForPortOperations() + { + var client = new TestDevTunnelClient + { + 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", new DevTunnelOptions + { + Region = DevTunnelRegion.NorthEurope + }).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_WithRegion_UsesResolvedTunnelIdForTunnelAndAccessOperations() + { + var client = new TestDevTunnelClient + { + TunnelStatus = new("mytunnel.eun1", HostConnections: 1, ClientConnections: 0, Description: "", Labels: []) + { + Ports = [ + new(5001, "https") + { + PortUri = new("https://mytunnel-5001.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", new DevTunnelOptions + { + Region = DevTunnelRegion.NorthEurope + }).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); + var calls = client.Calls.ToArray(); + Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.GetTunnelAsync) && call.TunnelId == "mytunnel.eun1"); + Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.GetAccessAsync) && call.TunnelId == "mytunnel.eun1" && call.PortNumber is null); + Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.GetAccessAsync) && call.TunnelId == "mytunnel.eun1" && call.PortNumber == 5001); + } + [Fact] public void GetEndpoint_WithResourceAndEndpointName_ReturnsTunnelEndpoint() { @@ -312,4 +415,10 @@ private sealed class TestResource(string name) : Resource(name), IResourceWithEn { } + + private sealed class TestRequiredCommandValidator : IRequiredCommandValidator + { + public Task ValidateAsync(IResource resource, RequiredCommandAnnotation annotation, CancellationToken cancellationToken) + => Task.FromResult(RequiredCommandValidationResult.Success()); + } } diff --git a/tests/Aspire.Hosting.DevTunnels.Tests/TestDevTunnelClient.cs b/tests/Aspire.Hosting.DevTunnels.Tests/TestDevTunnelClient.cs new file mode 100644 index 00000000000..2b85a788e88 --- /dev/null +++ b/tests/Aspire.Hosting.DevTunnels.Tests/TestDevTunnelClient.cs @@ -0,0 +1,79 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Globalization; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.DevTunnels.Tests; + +internal sealed class TestDevTunnelClient(Version? cliVersion = null) : IDevTunnelClient +{ + private readonly Version _cliVersion = cliVersion ?? DevTunnelCli.MinimumSupportedVersion; + + public ConcurrentQueue Calls { get; } = new(); + + public UserLoginStatus LoginStatus { get; set; } = new("Logged in", LoginProvider.Microsoft, "test-user"); + + public DevTunnelPortList PortList { get; set; } = new(); + + public DevTunnelStatus TunnelStatus { get; set; } = new("test-tunnel", HostConnections: 1, ClientConnections: 0, Description: "", Labels: []); + + public DevTunnelAccessStatus AccessStatus { get; set; } = new(); + + public Task GetVersionAsync(ILogger? logger = null, CancellationToken cancellationToken = default) + { + Calls.Enqueue(new(nameof(GetVersionAsync))); + return Task.FromResult(_cliVersion); + } + + public Task GetUserLoginStatusAsync(ILogger? logger = null, CancellationToken cancellationToken = default) + { + Calls.Enqueue(new(nameof(GetUserLoginStatusAsync))); + return Task.FromResult(LoginStatus); + } + + public Task UserLoginAsync(LoginProvider provider, ILogger? logger = null, CancellationToken cancellationToken = default) + { + Calls.Enqueue(new(nameof(UserLoginAsync))); + return Task.FromResult(LoginStatus with { Provider = provider }); + } + + 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: [])); + } + + public Task GetPortListAsync(string tunnelId, ILogger? logger = null, CancellationToken cancellationToken = default) + { + Calls.Enqueue(new(nameof(GetPortListAsync), tunnelId)); + return Task.FromResult(PortList); + } + + public Task CreatePortAsync(string tunnelId, int portNumber, DevTunnelPortOptions options, ILogger? logger = null, CancellationToken cancellationToken = default) + { + Calls.Enqueue(new(nameof(CreatePortAsync), tunnelId, portNumber)); + return Task.FromResult(new DevTunnelPortStatus(tunnelId, portNumber, options.Protocol ?? "https", ClientConnections: 0)); + } + + public Task DeletePortAsync(string tunnelId, int portNumber, ILogger? logger = null, CancellationToken cancellationToken = default) + { + Calls.Enqueue(new(nameof(DeletePortAsync), tunnelId, portNumber)); + return Task.FromResult(new DevTunnelPortDeleteResult(portNumber.ToString(CultureInfo.InvariantCulture))); + } + + public Task GetTunnelAsync(string tunnelId, ILogger? logger = null, CancellationToken cancellationToken = default) + { + Calls.Enqueue(new(nameof(GetTunnelAsync), tunnelId)); + return Task.FromResult(TunnelStatus); + } + + public Task GetAccessAsync(string tunnelId, int? portNumber = null, ILogger? logger = null, CancellationToken cancellationToken = default) + { + Calls.Enqueue(new(nameof(GetAccessAsync), tunnelId, portNumber)); + return Task.FromResult(AccessStatus); + } +} + +internal sealed record DevTunnelClientCall(string Method, string? TunnelId = null, int? PortNumber = null);