File: DevTunnelCliClient.cs
Web Access
Project: src\src\Aspire.Hosting.DevTunnels\Aspire.Hosting.DevTunnels.csproj (Aspire.Hosting.DevTunnels)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Configuration;
 
namespace Aspire.Hosting.DevTunnels;
 
internal sealed class DevTunnelCliClient(IConfiguration configuration) : IDevTunnelClient
{
    private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } };
    private readonly DevTunnelCli _cli = new(DevTunnelCli.GetCliPath(configuration));
 
    public async Task<DevTunnelStatus> CreateOrUpdateTunnelAsync(string tunnelId, DevTunnelOptions options, CancellationToken cancellationToken = default)
    {
        var (tunnel, exitCode, error) = await CallCliAsJsonAsync<DevTunnelStatus>(
            (stdout, stderr, ct) => _cli.CreateTunnelAsync(tunnelId, options, stdout, stderr, ct),
            "tunnel",
            cancellationToken).ConfigureAwait(false);
 
        if (exitCode == DevTunnelCli.ResourceConflictsWithExistingExitCode)
        {
            // Tunnel already exists
 
            // Reset the access controls to match the updated options
            (var access, exitCode, error) = await CallCliAsJsonAsync<DevTunnelAccessStatus>(
                (stdout, stderr, ct) => _cli.ResetAccessAsync(tunnelId, /* port */ null, stdout, stderr, ct),
                cancellationToken).ConfigureAwait(false);
 
            if (options.AllowAnonymous)
            {
                var anonymous = true;
                var deny = false;
                (access, exitCode, error) = await CallCliAsJsonAsync<DevTunnelAccessStatus>(
                    (stdout, stderr, ct) => _cli.CreateAccessAsync(tunnelId, /* port */ null, anonymous, deny, stdout, stderr, ct),
                    cancellationToken).ConfigureAwait(false);
            }
 
            if (exitCode == 0)
            {
                // Do the update
                (tunnel, exitCode, error) = await CallCliAsJsonAsync<DevTunnelStatus>(
                    (stdout, stderr, ct) => _cli.UpdateTunnelAsync(tunnelId, options, stdout, stderr, ct),
                    "tunnel",
                    cancellationToken).ConfigureAwait(false);
            }
        }
 
        return tunnel ?? throw new DistributedApplicationException($"Failed to create tunnel '{tunnelId}'. Exit code {exitCode}: {error}");
    }
 
    public async Task<DevTunnelStatus> GetTunnelAsync(string tunnelId, CancellationToken cancellationToken = default)
    {
        var (tunnel, exitCode, error) = await CallCliAsJsonAsync<DevTunnelStatus>(
            (stdout, stderr, ct) => _cli.ShowTunnelAsync(tunnelId, stdout, stderr, ct),
            "tunnel",
            cancellationToken).ConfigureAwait(false);
        return tunnel ?? throw new DistributedApplicationException($"Failed to get tunnel '{tunnelId}'. Exit code {exitCode}: {error}");
    }
 
    public async Task<DevTunnelPortStatus> CreateOrUpdatePortAsync(string tunnelId, int portNumber, DevTunnelPortOptions options, CancellationToken cancellationToken = default)
    {
        var (port, exitCode, error) = await CallCliAsJsonAsync<DevTunnelPortStatus>(
                (outWriter, errWriter, ct) => _cli.CreatePortAsync(tunnelId, portNumber, options, outWriter, errWriter, ct),
                cancellationToken).ConfigureAwait(false);
 
        if (exitCode == 0)
        {
            if (options.AllowAnonymous.HasValue)
            {
                // AllowAnonymous=true: anonymous=true, deny=false
                // AllowAnonymous=false: anonymous=true, deny=true
                var anonymous = true;
                var deny = !options.AllowAnonymous.Value;
                (var access, exitCode, error) = await CallCliAsJsonAsync<DevTunnelAccessStatus>(
                    (stdout, stderr, ct) => _cli.CreateAccessAsync(tunnelId, portNumber, anonymous, deny, stdout, stderr, ct),
                    cancellationToken).ConfigureAwait(false);
            }
        }
        else if (exitCode == DevTunnelCli.ResourceConflictsWithExistingExitCode)
        {
            // Port already exists
 
            // Reset the access controls to match the updated options
            (var access, exitCode, error) = await CallCliAsJsonAsync<DevTunnelAccessStatus>(
                (stdout, stderr, ct) => _cli.ResetAccessAsync(tunnelId, portNumber, stdout, stderr, ct),
                cancellationToken).ConfigureAwait(false);
 
            if (options.AllowAnonymous.HasValue)
            {
                // AllowAnonymous=true: anonymous=true, deny=false
                // AllowAnonymous=false: anonymous=true, deny=true
                var anonymous = true;
                var deny = !options.AllowAnonymous.Value;
                (access, exitCode, error) = await CallCliAsJsonAsync<DevTunnelAccessStatus>(
                    (stdout, stderr, ct) => _cli.CreateAccessAsync(tunnelId, portNumber, anonymous, deny, stdout, stderr, ct),
                    cancellationToken).ConfigureAwait(false);
            }
 
            if (exitCode == 0)
            {
                (port, exitCode, error) = await CallCliAsJsonAsync<DevTunnelPortStatus>(
                    (stdout, stderr, ct) => _cli.UpdatePortAsync(tunnelId, portNumber, options, stdout, stderr, ct),
                    "port",
                    cancellationToken).ConfigureAwait(false);
            }
        }
 
        return port ?? throw new DistributedApplicationException($"Failed to create port '{portNumber}' for tunnel '{tunnelId}'. Exit code {exitCode}: {error}");
    }
 
    public async Task<DevTunnelAccessStatus> GetAccessAsync(string tunnelId, int? portNumber = null, CancellationToken cancellationToken = default)
    {
        var (access, exitCode, error) = await CallCliAsJsonAsync<DevTunnelAccessStatus>(
            (stdout, stderr, ct) => _cli.ListAccessAsync(tunnelId, portNumber, stdout, stderr, ct),
            cancellationToken).ConfigureAwait(false);
        return access ?? throw new DistributedApplicationException($"Failed to get access details for '{tunnelId}'{(portNumber.HasValue ? $" port {portNumber}" : "")}. Exit code {exitCode}: {error}");
    }
 
    public async Task<UserLoginStatus> GetUserLoginStatusAsync(CancellationToken cancellationToken = default)
    {
        var (login, exitCode, error) = await CallCliAsJsonAsync<UserLoginStatus>(
            _cli.UserStatusAsync,
            cancellationToken).ConfigureAwait(false);
        return login ?? throw new DistributedApplicationException($"Failed to get user login status. Exit code {exitCode}: {error}");
    }
 
    public async Task<UserLoginStatus> UserLoginAsync(LoginProvider provider, CancellationToken cancellationToken = default)
    {
        var exitCode = provider switch
        {
            LoginProvider.Microsoft => await _cli.UserLoginMicrosoftAsync(cancellationToken).ConfigureAwait(false),
            LoginProvider.GitHub => await _cli.UserLoginGitHubAsync(cancellationToken).ConfigureAwait(false),
            _ => throw new ArgumentException("Unsupported provider. Supported providers are 'microsoft' and 'github'.", nameof(provider)),
        };
 
        if (exitCode == 0)
        {
            // Login succeeded, get the login status
            return await GetUserLoginStatusAsync(cancellationToken).ConfigureAwait(false);
        }
 
        throw new DistributedApplicationException($"Failed to perform user login. Process finished with exit code: {exitCode}");
    }
 
    private async Task<(T? Result, int ExitCode, string? Error)> CallCliAsJsonAsync<T>(Func<TextWriter, TextWriter, CancellationToken, Task<int>> cliCall, CancellationToken cancellationToken = default)
    {
        return await CallCliAsJsonAsync<T>(cliCall, propertyName: null, cancellationToken).ConfigureAwait(false);
    }
 
    private async Task<(T? Result, int ExitCode, string? Error)> CallCliAsJsonAsync<T>(Func<TextWriter, TextWriter, CancellationToken, Task<int>> cliCall, string? propertyName, CancellationToken cancellationToken = default)
    {
        // PERF: Could pool these writers
        using var stdout = new StringWriter();
        using var stderr = new StringWriter();
 
        var exitCode = await cliCall(stdout, stderr, cancellationToken).ConfigureAwait(false);
 
        if (exitCode != 0)
        {
            var error = stderr.ToString().Trim();
            return (default, exitCode, error);
        }
 
        var output = stdout.ToString().Trim();
        try
        {
            var result = propertyName switch
            {
                { Length: > 0 } => JsonSerializer.Deserialize<T>(JsonDocument.Parse(output).RootElement.GetProperty(propertyName).GetRawText(), _jsonOptions),
                _ => JsonSerializer.Deserialize<T>(output, _jsonOptions),
            };
            return (result, 0, default);
        }
        catch (JsonException ex)
        {
            throw new DistributedApplicationException($"Failed to parse JSON output into type '{typeof(T).Name}':\n{output}", ex);
        }
    }
}