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
11 changes: 11 additions & 0 deletions src/Aspire.Hosting.DevTunnels/DevTunnelCli.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public Task<int> CreateTunnelAsync(
return RunAsync(new ArgsBuilder(["create"])
.AddIfNotNull(tunnelId)
.AddIfNotNull("--description", options.Description)
.AddIfNotNull("--service-uri", GetServiceUri(options))
Comment thread
DamianEdwards marked this conversation as resolved.
.AddIfTrue("--allow-anonymous", options.AllowAnonymous)
.AddValues("--labels", options.Labels)
.Add("--json")
Expand Down Expand Up @@ -391,6 +392,16 @@ private static async Task PumpAsync(StreamReader reader, Action<string> 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<string> initialArgs)
Expand Down
30 changes: 15 additions & 15 deletions src/Aspire.Hosting.DevTunnels/DevTunnelCliClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public async Task<DevTunnelStatus> 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)
{
Expand All @@ -62,8 +63,7 @@ public async Task<DevTunnelStatus> CreateTunnelAsync(string tunnelId, DevTunnelO
{
logger?.LogTrace("Attempt {Attempt} of {MaxAttempts} to create dev tunnel '{TunnelId}'", attempts, _maxCliAttempts, tunnelId);
}
(var tunnel, exitCode, error) = await CallCliAsJsonAsync<DevTunnelStatus>(
(stdout, stderr, log, ct) => _cli.CreateTunnelAsync(tunnelId, options, stdout, stderr, log, ct),
(var tunnel, exitCode, error) = await CallCliAsJsonAsync<DevTunnelStatus>((stdout, stderr, log, ct) => _cli.CreateTunnelAsync(tunnelId, options, stdout, stderr, log, ct),
"tunnel",
logger, cancellationToken).ConfigureAwait(false);

Expand All @@ -78,31 +78,31 @@ public async Task<DevTunnelStatus> 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<DevTunnelStatus>(
(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<DevTunnelAccessStatus>(
(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),
Comment thread
DamianEdwards marked this conversation as resolved.
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<DevTunnelAccessStatus>(
(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)
Expand Down Expand Up @@ -143,7 +143,7 @@ public async Task<DevTunnelPortList> 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<DevTunnelPortStatus> CreatePortAsync(string tunnelId, int portNumber, DevTunnelPortOptions options, ILogger? logger = default, CancellationToken cancellationToken = default)
public async Task<DevTunnelPortStatus> CreatePortAsync(string tunnelId, int portNumber, DevTunnelPortOptions portOptions, ILogger? logger = default, CancellationToken cancellationToken = default)
{
var attempts = 0;
var exitCode = 0;
Expand All @@ -152,24 +152,24 @@ public async Task<DevTunnelPortStatus> 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<DevTunnelPortStatus>(
(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);
Expand Down
6 changes: 3 additions & 3 deletions src/Aspire.Hosting.DevTunnels/DevTunnelHealthCheck.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public async Task<HealthCheckResult> 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)
{
Expand All @@ -44,14 +44,14 @@ public async Task<HealthCheckResult> 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;
}

Expand Down
102 changes: 101 additions & 1 deletion src/Aspire.Hosting.DevTunnels/DevTunnelOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,36 @@ public sealed class DevTunnelOptions
/// </summary>
public List<string>? Labels { get; set; }

internal string ToLoggerString() => $"{{ Description={Description}, AllowAnonymous={AllowAnonymous}, Labels=[{string.Join(", ", Labels ?? [])}] }}";
/// <summary>
/// Optional region to create the dev tunnel in.
/// If not specified, the region will be selected automatically based on the ping.
/// </summary>
/// <remarks>
/// Set this value, for example to <code>DevTunnelRegion.NorthEurope</code>, when an existing tunnel must be reused in a specific service region.
/// </remarks>
public DevTunnelRegion? Region { get; set; }

Comment thread
DamianEdwards marked this conversation as resolved.
Comment on lines +26 to +34

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Leaving the generated src/Aspire.Hosting.DevTunnels/api/Aspire.Hosting.DevTunnels.cs file unchanged per the repo guidance. API files are regenerated during the release process rather than manually updated during individual PR development.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The guidance is in the repo-level agent instructions under Code Review Instructions → API Files and Public API Surface. Relevant text:

The API files located in */api/*.cs ... are auto-generated and serve as a baseline for API compatibility checks.

Do not comment when new public API is introduced and the API files are not regenerated. This is expected behavior during active development between releases.

API files are regenerated as part of the release process when we ship a new version, not during individual PRs.

Also the general repo guidance says:

Don't update files under */api/*.cs ... as they are generated.

internal string RegionCode =>
Region switch
{
DevTunnelRegion.WestEurope => "euw",
Comment thread
DamianEdwards marked this conversation as resolved.
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} }}";
}

/// <summary>
Expand Down Expand Up @@ -53,3 +82,74 @@ public sealed class DevTunnelPortOptions

internal string ToLoggerString() => $"{{ Description={Description}, AllowAnonymous={AllowAnonymous}, Protocol={Protocol}, Labels=[{string.Join(", ", Labels ?? [])}] }}";
}

/// <summary>
/// Region options for dev tunnel creation.
/// </summary>
public enum DevTunnelRegion : byte
{
/// <summary>
/// West Europe region.
/// </summary>
WestEurope,

/// <summary>
/// UK South region.
/// </summary>
UkSouth,

/// <summary>
/// North Europe region.
/// </summary>
NorthEurope,

/// <summary>
/// East US region.
/// </summary>
EastUs,

/// <summary>
/// East US 2 region.
/// </summary>
EastUs2,

/// <summary>
/// Central India region.
/// </summary>
CentralIndia,

/// <summary>
/// West US 3 region.
/// </summary>
WestUs3,

/// <summary>
/// West US 2 region.
/// </summary>
WestUs2,

/// <summary>
/// Southeast Asia region.
/// </summary>
SouthEastAsia,

/// <summary>
/// Brazil South region.
/// </summary>
BrazilSouth,

/// <summary>
/// Australia Central region.
/// </summary>
AustraliaCentral,

/// <summary>
/// Australia East region.
/// </summary>
AustraliaEast,

/// <summary>
/// Japan East region.
/// </summary>
JapanEast
}
5 changes: 5 additions & 0 deletions src/Aspire.Hosting.DevTunnels/DevTunnelResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ public sealed class DevTunnelResource(string name, string tunnelId, string comma
/// </summary>
public string TunnelId { get; init; } = tunnelId;

/// <summary>
/// Gets the fully qualified tunnel ID including the region suffix if a region is specified.
/// </summary>
internal string ResolvedTunnelId => Options.Region is not null ? $"{TunnelId}.{Options.RegionCode}" : TunnelId;

internal List<DevTunnelPortResource> Ports { get; } = [];

internal DevTunnelStatus? LastKnownStatus { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ public static IResourceBuilder<DevTunnelResource> AddDevTunnel(
#pragma warning restore ASPIREINTERACTION001

var rb = builder.AddResource(tunnelResource)
.WithArgs("host", tunnelId, "--nologo")
.WithArgs("host", tunnelResource.ResolvedTunnelId, "--nologo")
Comment thread
DamianEdwards marked this conversation as resolved.
.WithIconName("CloudBidirectional")
Comment thread
DamianEdwards marked this conversation as resolved.
.WithEnvironment("TUNNEL_SERVICE_USER_AGENT", s_aspireUserAgent)
.WithInitialState(new()
Expand Down Expand Up @@ -176,13 +176,13 @@ public static IResourceBuilder<DevTunnelResource> 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);
}
}

Expand All @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions src/Aspire.Hosting.DevTunnels/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting.DevTunnels.Tests;

Expand Down Expand Up @@ -46,48 +45,4 @@ public async Task ValidateDevTunnelCliVersionAsync_ReturnsInvalidForUnsupportedV
}
}

private sealed class TestDevTunnelClient(Version cliVersion) : IDevTunnelClient
{
public Task<Version> GetVersionAsync(ILogger? logger = null, CancellationToken cancellationToken = default) => Task.FromResult(cliVersion);

public Task<DevTunnelPortStatus> CreatePortAsync(string tunnelId, int portNumber, DevTunnelPortOptions options, ILogger? logger = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}

public Task<DevTunnelStatus> CreateTunnelAsync(string tunnelId, DevTunnelOptions options, ILogger? logger = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}

public Task<DevTunnelAccessStatus> GetAccessAsync(string tunnelId, int? portNumber = null, ILogger? logger = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}

public Task<DevTunnelStatus> GetTunnelAsync(string tunnelId, ILogger? logger = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}

public Task<UserLoginStatus> GetUserLoginStatusAsync(ILogger? logger = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}

public Task<UserLoginStatus> UserLoginAsync(LoginProvider provider, ILogger? logger = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}

public Task<DevTunnelPortList> GetPortListAsync(string tunnelId, ILogger? logger = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}

public Task<DevTunnelPortDeleteResult> DeletePortAsync(string tunnelId, int portNumber, ILogger? logger = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
}
}
Loading
Loading