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
27 changes: 27 additions & 0 deletions cs/src/Contracts/ClusterAvailability.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// <copyright file="ClusterAvailability.cs" company="Microsoft">
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license.
// </copyright>

namespace Microsoft.DevTunnels.Contracts;

/// <summary>
/// Availability status of a tunneling service cluster.
/// </summary>
public enum ClusterAvailability
{
/// <summary>
/// Cluster has sufficient capacity and is fully available.
/// </summary>
Available,

/// <summary>
/// Cluster is approaching capacity limits and may experience delays.
/// </summary>
Degraded,

/// <summary>
/// Cluster is at or beyond capacity and should not be used for new tunnels.
/// </summary>
Unavailable,
}
47 changes: 47 additions & 0 deletions cs/src/Contracts/ClusterRecommendation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// <copyright file="ClusterRecommendation.cs" company="Microsoft">
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license.
// </copyright>

namespace Microsoft.DevTunnels.Contracts;

/// <summary>
/// A single cluster recommendation with availability and capacity details.
/// </summary>
public class ClusterRecommendation
{
/// <summary>
/// Gets or sets the cluster ID, e.g. "usw2".
/// </summary>
public string ClusterId { get; set; } = null!;

/// <summary>
/// Gets or sets the Azure location name, e.g. "WestUs2".
/// </summary>
public string AzureLocation { get; set; } = null!;

/// <summary>
/// Gets or sets the Azure geography name for data residency, e.g. "United States".
/// </summary>
public string AzureGeo { get; set; } = null!;

/// <summary>
/// Gets or sets the cluster URI for API requests.
/// </summary>
public string ClusterUri { get; set; } = null!;

/// <summary>
/// Gets or sets the availability status of the cluster.
/// </summary>
public ClusterAvailability Availability { get; set; }

/// <summary>
/// Gets or sets the utilization percentage of the cluster.
/// </summary>
public double UtilizationPercent { get; set; }

/// <summary>
/// Gets or sets a human-readable reason for this recommendation's ranking.
/// </summary>
public string Reason { get; set; } = null!;
}
37 changes: 37 additions & 0 deletions cs/src/Contracts/ClusterRecommendationResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// <copyright file="ClusterRecommendationResponse.cs" company="Microsoft">
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license.
// </copyright>

using System;

namespace Microsoft.DevTunnels.Contracts;

/// <summary>
/// Response from the cluster recommendation API containing ranked cluster recommendations.
/// </summary>
public class ClusterRecommendationResponse
{
/// <summary>
/// Gets or sets the preferred cluster ID that was requested, if any.
/// </summary>
public string? PreferredClusterId { get; set; }

/// <summary>
/// Gets or sets the recommended cluster ID — the best available cluster.
/// Null if no clusters are available.
/// </summary>
public string? RecommendedClusterId { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the recommendation differs
/// from the preferred cluster.
/// </summary>
public bool IsFallback { get; set; }

/// <summary>
/// Gets or sets the ordered list of cluster recommendations, ranked by preference.
/// </summary>
public ClusterRecommendation[] Recommendations { get; set; }
= Array.Empty<ClusterRecommendation>();
}
19 changes: 19 additions & 0 deletions cs/src/Management/ITunnelManagementClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,25 @@ Task<TunnelAccessSubject[]> ResolveSubjectsAsync(
/// <returns>Array of <see cref="ClusterDetails"/></returns>
Task<ClusterDetails[]> ListClustersAsync(CancellationToken cancellation = default);

/// <summary>
/// Gets cluster recommendations for tunnel creation based on capacity and
/// availability.
/// </summary>
/// <param name="preferredClusterId">
/// Optional preferred cluster ID. When omitted, defaults to the cluster
/// serving the request.
/// </param>
/// <param name="requiredGeo">
/// Optional Azure geography filter. When specified, only clusters in
/// this geo are eligible for recommendation.
/// </param>
/// <param name="cancellation">Cancellation token.</param>
/// <returns>Cluster recommendation response with ranked clusters.</returns>
Task<ClusterRecommendationResponse> GetClusterRecommendationsAsync(
string? preferredClusterId = null,
string? requiredGeo = null,
CancellationToken cancellation = default);

/// <summary>
/// Checks for tunnel name availability.
/// </summary>
Expand Down
64 changes: 64 additions & 0 deletions cs/src/Management/TunnelManagementClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public class TunnelManagementClient : ITunnelManagementClient
private const string EventsApiSubPath = "/events";
private const string ClustersApiPath = "/clusters";
private const string ClustersV1ApiPath = ApiV1Path + "/clusters";
private const string RecommendationsSubPath = "/recommendations";
private const string TunnelAuthenticationScheme = "Tunnel";
private const string RequestIdHeaderName = "VsSaaS-Request-Id";
private const string CheckAvailableSubPath = ":checkNameAvailability";
Expand Down Expand Up @@ -1084,6 +1085,29 @@ public async Task<Tunnel> CreateTunnelAsync(
{
Requires.NotNull(tunnel, nameof(tunnel));
options ??= new TunnelRequestOptions();

// If the caller didn't specify a cluster, auto-select one via the
// recommendations API. Failures fall back to global routing.
if (string.IsNullOrEmpty(tunnel.ClusterId))
{
try
{
var recommendations = await GetClusterRecommendationsAsync(
preferredClusterId: null,
requiredGeo: options.RequiredGeo,
cancellation);
if (!string.IsNullOrEmpty(recommendations?.RecommendedClusterId))
{
tunnel.ClusterId = recommendations!.RecommendedClusterId;
}
}
catch (Exception) when (!cancellation.IsCancellationRequested)
{
// Fall through to global (Traffic Manager) routing if the
// recommendations request fails for any reason.
}
}

options.AdditionalHeaders ??= new List<KeyValuePair<string, string>>();
options.AdditionalHeaders = options.AdditionalHeaders.Append(
new KeyValuePair<string, string>("If-None-Match", "*"));
Expand Down Expand Up @@ -1595,6 +1619,46 @@ public async Task<ClusterDetails[]> ListClustersAsync(CancellationToken cancella
return clusterDetails!;
}

/// <inheritdoc/>
public async Task<ClusterRecommendationResponse> GetClusterRecommendationsAsync(
string? preferredClusterId = null,
string? requiredGeo = null,
CancellationToken cancellation = default)
{
var baseAddress = this.httpClient.BaseAddress!;
var builder = new UriBuilder(baseAddress);
builder.Path = ClustersPath + RecommendationsSubPath;

var queryParts = new List<string>();
var apiQuery = GetApiQuery();
if (!string.IsNullOrEmpty(apiQuery))
{
queryParts.Add(apiQuery!);
}

if (!string.IsNullOrEmpty(preferredClusterId))
{
queryParts.Add(
$"preferredClusterId={Uri.EscapeDataString(preferredClusterId!)}");
}

if (!string.IsNullOrEmpty(requiredGeo))
{
queryParts.Add($"requiredGeo={Uri.EscapeDataString(requiredGeo!)}");
}

builder.Query = string.Join("&", queryParts);

var response = await SendRequestAsync<object, ClusterRecommendationResponse>(
HttpMethod.Get,
builder.Uri,
options: null,
authHeader: null,
body: null,
cancellation);
return response!;
}

/// <inheritdoc/>
public async Task<bool> CheckNameAvailabilityAsync(
string name,
Expand Down
14 changes: 14 additions & 0 deletions cs/src/Management/TunnelRequestOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,20 @@ public class TunnelRequestOptions
/// </summary>
public uint? Limit { get; set; }

/// <summary>
/// Gets or sets an optional Azure geography filter used when a cluster is
/// automatically recommended during tunnel creation.
/// </summary>
/// <remarks>
/// This option only applies to <see cref="ITunnelManagementClient.CreateTunnelAsync"/>
/// when the tunnel does not already specify a <see cref="Tunnel.ClusterId"/>. In that
/// case the value is forwarded to the cluster recommendations request so that only
/// clusters in the specified geo are eligible for automatic selection. It has no effect
/// when a cluster is explicitly set or on any other request, and it is not sent as part
/// of the create-tunnel request itself.
/// </remarks>
public string? RequiredGeo { get; set; }

/// <summary>
/// Converts tunnel request options to a query string for HTTP requests to the
/// tunnel management API.
Expand Down
19 changes: 19 additions & 0 deletions cs/test/TunnelsSDK.Test/Mocks/MockTunnelManagementClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,25 @@ public Task<ClusterDetails[]> ListClustersAsync(CancellationToken cancellation =
throw new NotImplementedException();
}

public Task<ClusterRecommendationResponse> GetClusterRecommendationsAsync(
string preferredClusterId = null,
string requiredGeo = null,
CancellationToken cancellation = default)
{
return Task.FromResult(new ClusterRecommendationResponse
{
RecommendedClusterId = "localhost",
Recommendations = new[]
{
new ClusterRecommendation
{
ClusterId = "localhost",
Availability = ClusterAvailability.Available,
},
},
});
}

public Task<bool> CheckNameAvailabilityAsync(string name, CancellationToken cancellation = default)
{
throw new NotImplementedException();
Expand Down
Loading
Loading