Skip to content

Commit cf3b27a

Browse files
Ensure devtunnels CLI is at least minimum required version (#11439)
* Ensure devtunnel CLI min version Fixes #11342 * Added a test for min version logic
1 parent f25680a commit cf3b27a

File tree

6 files changed

+215
-9
lines changed

6 files changed

+215
-9
lines changed

src/Aspire.Hosting.DevTunnels/DevTunnelCli.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ internal sealed class DevTunnelCli
1616
public const int ResourceConflictsWithExistingExitCode = 1;
1717
public const int ResourceNotFoundExitCode = 2;
1818

19+
public static readonly Version MinimumSupportedVersion = new(1, 0, 1435);
20+
1921
private readonly string _cliPath;
2022

2123
public static string GetCliPath(IConfiguration configuration) => configuration["ASPIRE_DEVTUNNEL_CLI_PATH"] ?? "devtunnel";
@@ -30,6 +32,9 @@ public DevTunnelCli(string filePath)
3032
_cliPath = filePath;
3133
}
3234

35+
public Task<int> GetVersionAsync(TextWriter? outputWriter = null, TextWriter? errorWriter = null, ILogger? logger = default, CancellationToken cancellationToken = default)
36+
=> RunAsync(["--version", "--nologo"], outputWriter, errorWriter, logger, cancellationToken);
37+
3338
public Task<int> UserLoginMicrosoftAsync(ILogger? logger = default, CancellationToken cancellationToken = default)
3439
=> RunAsync(["user", "login", "--entra", "--json", "--nologo"], null, null, useShellExecute: true, logger, cancellationToken);
3540

src/Aspire.Hosting.DevTunnels/DevTunnelCliClient.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,40 @@ internal sealed class DevTunnelCliClient(IConfiguration configuration) : IDevTun
1515
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } };
1616
private readonly DevTunnelCli _cli = new(DevTunnelCli.GetCliPath(configuration));
1717

18+
public async Task<Version> GetVersionAsync(ILogger? logger = default, CancellationToken cancellationToken = default)
19+
{
20+
using var outputWriter = new StringWriter();
21+
using var errorWriter = new StringWriter();
22+
23+
var exitCode = await _cli.GetVersionAsync(outputWriter, errorWriter, logger, cancellationToken).ConfigureAwait(false);
24+
var output = outputWriter.ToString().Trim();
25+
26+
if (exitCode == 0)
27+
{
28+
// Find the line with the version number. It will look like "Tunnel CLI version: 1.0.1435+d49a94cc24"
29+
var prefix = "Tunnel CLI version:";
30+
var versionLine = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
31+
.FirstOrDefault(l => l.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
32+
var versionString = versionLine?.Length > prefix.Length
33+
? versionLine[prefix.Length..].Trim()
34+
: output;
35+
36+
// Trim the commit SHA suffix if present
37+
if (versionString.IndexOf('+') is >= 0 and var plusIndex)
38+
{
39+
versionString = versionString[..plusIndex];
40+
}
41+
42+
if (Version.TryParse(versionString, out var version))
43+
{
44+
return version;
45+
}
46+
}
47+
48+
var error = errorWriter.ToString().Trim();
49+
throw new DistributedApplicationException($"Failed to get devtunnel CLI version. Output: '{output}'. Error: '{error}'");
50+
}
51+
1852
public async Task<DevTunnelStatus> CreateTunnelAsync(string tunnelId, DevTunnelOptions options, ILogger? logger = default, CancellationToken cancellationToken = default)
1953
{
2054
var attempts = 0;

src/Aspire.Hosting.DevTunnels/DevTunnelCliInstallationManager.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,36 @@ namespace Aspire.Hosting.DevTunnels;
88

99
internal sealed class DevTunnelCliInstallationManager : RequiredCommandValidator
1010
{
11+
private readonly IDevTunnelClient _devTunnelClient;
1112
private readonly IConfiguration _configuration;
13+
private readonly ILogger _logger;
14+
private readonly Version _minSupportedVersion;
1215
private string? _resolvedCommandPath;
1316

1417
#pragma warning disable ASPIREINTERACTION001 // Interaction service is experimental.
1518
public DevTunnelCliInstallationManager(
19+
IDevTunnelClient devTunnelClient,
1620
IConfiguration configuration,
1721
IInteractionService interactionService,
1822
ILogger<DevTunnelCliInstallationManager> logger)
23+
: this(devTunnelClient, configuration, interactionService, logger, DevTunnelCli.MinimumSupportedVersion)
24+
{
25+
26+
}
27+
28+
public DevTunnelCliInstallationManager(
29+
IDevTunnelClient devTunnelClient,
30+
IConfiguration configuration,
31+
IInteractionService interactionService,
32+
ILogger<DevTunnelCliInstallationManager> logger,
33+
Version minSupportedVersion)
1934
: base(interactionService, logger)
2035
#pragma warning restore ASPIREINTERACTION001
2136
{
37+
_devTunnelClient = devTunnelClient ?? throw new ArgumentNullException(nameof(devTunnelClient));
2238
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
39+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
40+
_minSupportedVersion = minSupportedVersion ?? throw new ArgumentNullException(nameof(minSupportedVersion));
2341
}
2442

2543
/// <summary>
@@ -41,6 +59,17 @@ public DevTunnelCliInstallationManager(
4159

4260
protected override string GetCommandPath() => DevTunnelCli.GetCliPath(_configuration);
4361

62+
protected internal override async Task<(bool IsValid, string? ValidationMessage)> OnResolvedAsync(string resolvedCommandPath, CancellationToken cancellationToken)
63+
{
64+
// Verify the version is supported
65+
var version = await _devTunnelClient.GetVersionAsync(_logger, cancellationToken).ConfigureAwait(false);
66+
if (version < _minSupportedVersion)
67+
{
68+
return (false, $"The installed devtunnel CLI version {version} is not supported. Version {_minSupportedVersion} or higher is required.");
69+
}
70+
return (true, null);
71+
}
72+
4473
protected override Task OnValidatedAsync(string resolvedCommandPath, CancellationToken cancellationToken)
4574
{
4675
_resolvedCommandPath = resolvedCommandPath;

src/Aspire.Hosting.DevTunnels/IDevTunnelClient.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ namespace Aspire.Hosting.DevTunnels;
77

88
internal interface IDevTunnelClient
99
{
10+
Task<Version> GetVersionAsync(ILogger? logger = default, CancellationToken cancellationToken = default);
11+
1012
Task<UserLoginStatus> GetUserLoginStatusAsync(ILogger? logger = default, CancellationToken cancellationToken = default);
1113

1214
Task<UserLoginStatus> UserLoginAsync(LoginProvider provider, ILogger? logger = default, CancellationToken cancellationToken = default);

src/Aspire.Hosting.DevTunnels/RequiredCommandValidator.cs

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,22 @@ internal abstract class RequiredCommandValidator(IInteractionService interaction
2626
private readonly ILogger _logger = logger;
2727

2828
private Task? _notificationTask;
29+
private string? _notificationMessage;
2930

3031
/// <summary>
3132
/// Returns the command string (file name or path) that should be validated.
3233
/// </summary>
3334
protected abstract string GetCommandPath();
3435

36+
/// <summary>
37+
/// Called after the command has been successfully resolved to a full path.
38+
/// Default implementation does nothing.
39+
/// </summary>
40+
/// <remarks>
41+
/// Overrides can perform additional validation to verify the command is usable.
42+
/// </remarks>
43+
protected internal virtual Task<(bool IsValid, string? ValidationMessage)> OnResolvedAsync(string resolvedCommandPath, CancellationToken cancellationToken) => Task.FromResult((true, (string?)null));
44+
3545
/// <summary>
3646
/// Called after the command has been successfully validated and resolved to a full path.
3747
/// Default implementation does nothing.
@@ -49,23 +59,34 @@ protected sealed override async Task ExecuteCoreAsync(CancellationToken cancella
4959
if (notificationTask is { IsCompleted: false })
5060
{
5161
// Failure notification is still being shown so just throw again.
52-
throw GetCommandNotFoundException(command);
62+
throw new DistributedApplicationException(_notificationMessage ?? $"Required command '{command}' was not found on PATH, at the specified location, or failed validation.");
5363
}
5464

5565
if (string.IsNullOrWhiteSpace(command))
5666
{
5767
throw new InvalidOperationException("Command path cannot be null or empty.");
5868
}
5969
var resolved = ResolveCommand(command);
60-
if (resolved is null)
70+
var isValid = true;
71+
string? validationMessage = null;
72+
if (resolved is not null)
73+
{
74+
(isValid, validationMessage) = await OnResolvedAsync(resolved, cancellationToken).ConfigureAwait(false);
75+
}
76+
if (resolved is null || !isValid)
6177
{
6278
var link = GetHelpLink();
63-
var message = link is null
64-
? $"Required command '{command}' was not found on PATH or at a specified location."
65-
: $"Required command '{command}' was not found. See installation instructions for more details.";
79+
var message = (link, validationMessage) switch
80+
{
81+
(null, not null) => validationMessage,
82+
(not null, not null) => $"{validationMessage} See installation instructions for more details.",
83+
(not null, null) => $"Required command '{command}' was not found. See installation instructions for more details.",
84+
_ => $"Required command '{command}' was not found on PATH or at a specified location."
85+
};
6686

6787
_logger.LogWarning("{Message}", message);
6888

89+
_notificationMessage = message;
6990
if (_interactionService.IsAvailable == true)
7091
{
7192
try
@@ -91,15 +112,13 @@ protected sealed override async Task ExecuteCoreAsync(CancellationToken cancella
91112
_logger.LogDebug(ex, "Failed to show missing command notification");
92113
}
93114
}
94-
throw GetCommandNotFoundException(command);
115+
throw new DistributedApplicationException(message);
95116
}
96117

118+
_notificationMessage = null;
97119
await OnValidatedAsync(resolved, cancellationToken).ConfigureAwait(false);
98120
}
99121

100-
private static DistributedApplicationException GetCommandNotFoundException(string command) =>
101-
new($"Required command '{command}' was not found on PATH or at the specified location.");
102-
103122
/// <summary>
104123
/// Optional link returned to guide users when the command is missing. Return null for no link.
105124
/// </summary>
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.Extensions.Configuration;
5+
using Microsoft.Extensions.Logging;
6+
using Microsoft.Extensions.Logging.Abstractions;
7+
8+
namespace Aspire.Hosting.DevTunnels.Tests;
9+
10+
public class DevTunnelCliInstallationManagerTests
11+
{
12+
[Theory]
13+
[InlineData("1.0.1435", "1.0.1435", true)]
14+
[InlineData("1.0.1435", "1.0.1436", true)]
15+
[InlineData("1.0.1435", "1.1.1234", true)]
16+
[InlineData("1.0.1435", "2.0.0", true)]
17+
[InlineData("1.0.1435", "10.0.0", true)]
18+
[InlineData("1.9.0", "1.10.0", true)]
19+
[InlineData("1.0.1435", "1.0.1434", false)]
20+
[InlineData("1.0.1435", "1.0.0", false)]
21+
[InlineData("1.0.1435", "0.0.1", false)]
22+
[InlineData("1.2.0", "1.1.999", false)]
23+
public async Task OnResolvedAsync_ReturnsInvalidForUnsupportedVersion(string minVersion, string testVersion, bool expectedIsValid)
24+
{
25+
var logger = NullLoggerFactory.Instance.CreateLogger<DevTunnelCliInstallationManager>();
26+
var configuration = new ConfigurationBuilder().Build();
27+
var testCliVersion = Version.Parse(testVersion);
28+
var devTunnelClient = new TestDevTunnelClient(testCliVersion);
29+
30+
var manager = new DevTunnelCliInstallationManager(devTunnelClient, configuration, new TestInteractionService(), logger, Version.Parse(minVersion));
31+
32+
var (isValid, validationMessage) = await manager.OnResolvedAsync("thepath", CancellationToken.None);
33+
34+
Assert.Equal(expectedIsValid, isValid);
35+
if (expectedIsValid)
36+
{
37+
Assert.Null(validationMessage);
38+
}
39+
else
40+
{
41+
Assert.Contains(minVersion, validationMessage);
42+
Assert.Contains(testVersion, validationMessage);
43+
}
44+
}
45+
46+
#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
47+
private sealed class TestInteractionService : IInteractionService
48+
{
49+
public bool IsAvailable => true;
50+
51+
public Task<InteractionResult<bool>> PromptConfirmationAsync(string title, string message, MessageBoxInteractionOptions? options = null, CancellationToken cancellationToken = default)
52+
{
53+
throw new NotImplementedException();
54+
}
55+
56+
public Task<InteractionResult<InteractionInput>> PromptInputAsync(string title, string? message, string inputLabel, string placeHolder, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default)
57+
{
58+
throw new NotImplementedException();
59+
}
60+
61+
public Task<InteractionResult<InteractionInput>> PromptInputAsync(string title, string? message, InteractionInput input, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default)
62+
{
63+
throw new NotImplementedException();
64+
}
65+
66+
public Task<InteractionResult<InteractionInputCollection>> PromptInputsAsync(string title, string? message, IReadOnlyList<InteractionInput> inputs, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default)
67+
{
68+
throw new NotImplementedException();
69+
}
70+
71+
public Task<InteractionResult<bool>> PromptMessageBoxAsync(string title, string message, MessageBoxInteractionOptions? options = null, CancellationToken cancellationToken = default)
72+
{
73+
throw new NotImplementedException();
74+
}
75+
76+
public Task<InteractionResult<bool>> PromptNotificationAsync(string title, string message, NotificationInteractionOptions? options = null, CancellationToken cancellationToken = default)
77+
{
78+
throw new NotImplementedException();
79+
}
80+
}
81+
#pragma warning restore ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
82+
83+
private sealed class TestDevTunnelClient(Version cliVersion) : IDevTunnelClient
84+
{
85+
public Task<Version> GetVersionAsync(ILogger? logger = null, CancellationToken cancellationToken = default) => Task.FromResult(cliVersion);
86+
87+
public Task<DevTunnelPortStatus> CreatePortAsync(string tunnelId, int portNumber, DevTunnelPortOptions options, ILogger? logger = null, CancellationToken cancellationToken = default)
88+
{
89+
throw new NotImplementedException();
90+
}
91+
92+
public Task<DevTunnelStatus> CreateTunnelAsync(string tunnelId, DevTunnelOptions options, ILogger? logger = null, CancellationToken cancellationToken = default)
93+
{
94+
throw new NotImplementedException();
95+
}
96+
97+
public Task<DevTunnelAccessStatus> GetAccessAsync(string tunnelId, int? portNumber = null, ILogger? logger = null, CancellationToken cancellationToken = default)
98+
{
99+
throw new NotImplementedException();
100+
}
101+
102+
public Task<DevTunnelStatus> GetTunnelAsync(string tunnelId, ILogger? logger = null, CancellationToken cancellationToken = default)
103+
{
104+
throw new NotImplementedException();
105+
}
106+
107+
public Task<UserLoginStatus> GetUserLoginStatusAsync(ILogger? logger = null, CancellationToken cancellationToken = default)
108+
{
109+
throw new NotImplementedException();
110+
}
111+
112+
public Task<UserLoginStatus> UserLoginAsync(LoginProvider provider, ILogger? logger = null, CancellationToken cancellationToken = default)
113+
{
114+
throw new NotImplementedException();
115+
}
116+
}
117+
}

0 commit comments

Comments
 (0)