Skip to content

Commit ad5419c

Browse files
authored
Merge branch 'main' into dev/nkotchar/UpdateSSHPackage31236
2 parents a22bb71 + a18b41f commit ad5419c

35 files changed

Lines changed: 1511 additions & 3 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// <copyright file="ClusterAvailability.cs" company="Microsoft">
2+
// Copyright (c) Microsoft. All rights reserved.
3+
// Licensed under the MIT license.
4+
// </copyright>
5+
6+
namespace Microsoft.DevTunnels.Contracts;
7+
8+
/// <summary>
9+
/// Availability status of a tunneling service cluster.
10+
/// </summary>
11+
public enum ClusterAvailability
12+
{
13+
/// <summary>
14+
/// Cluster has sufficient capacity and is fully available.
15+
/// </summary>
16+
Available,
17+
18+
/// <summary>
19+
/// Cluster is approaching capacity limits and may experience delays.
20+
/// </summary>
21+
Degraded,
22+
23+
/// <summary>
24+
/// Cluster is at or beyond capacity and should not be used for new tunnels.
25+
/// </summary>
26+
Unavailable,
27+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// <copyright file="ClusterRecommendation.cs" company="Microsoft">
2+
// Copyright (c) Microsoft. All rights reserved.
3+
// Licensed under the MIT license.
4+
// </copyright>
5+
6+
namespace Microsoft.DevTunnels.Contracts;
7+
8+
/// <summary>
9+
/// A single cluster recommendation with availability and capacity details.
10+
/// </summary>
11+
public class ClusterRecommendation
12+
{
13+
/// <summary>
14+
/// Gets or sets the cluster ID, e.g. "usw2".
15+
/// </summary>
16+
public string ClusterId { get; set; } = null!;
17+
18+
/// <summary>
19+
/// Gets or sets the Azure location name, e.g. "WestUs2".
20+
/// </summary>
21+
public string AzureLocation { get; set; } = null!;
22+
23+
/// <summary>
24+
/// Gets or sets the Azure geography name for data residency, e.g. "United States".
25+
/// </summary>
26+
public string AzureGeo { get; set; } = null!;
27+
28+
/// <summary>
29+
/// Gets or sets the cluster URI for API requests.
30+
/// </summary>
31+
public string ClusterUri { get; set; } = null!;
32+
33+
/// <summary>
34+
/// Gets or sets the availability status of the cluster.
35+
/// </summary>
36+
public ClusterAvailability Availability { get; set; }
37+
38+
/// <summary>
39+
/// Gets or sets the utilization percentage of the cluster.
40+
/// </summary>
41+
public double UtilizationPercent { get; set; }
42+
43+
/// <summary>
44+
/// Gets or sets a human-readable reason for this recommendation's ranking.
45+
/// </summary>
46+
public string Reason { get; set; } = null!;
47+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// <copyright file="ClusterRecommendationResponse.cs" company="Microsoft">
2+
// Copyright (c) Microsoft. All rights reserved.
3+
// Licensed under the MIT license.
4+
// </copyright>
5+
6+
using System;
7+
8+
namespace Microsoft.DevTunnels.Contracts;
9+
10+
/// <summary>
11+
/// Response from the cluster recommendation API containing ranked cluster recommendations.
12+
/// </summary>
13+
public class ClusterRecommendationResponse
14+
{
15+
/// <summary>
16+
/// Gets or sets the preferred cluster ID that was requested, if any.
17+
/// </summary>
18+
public string? PreferredClusterId { get; set; }
19+
20+
/// <summary>
21+
/// Gets or sets the recommended cluster ID — the best available cluster.
22+
/// Null if no clusters are available.
23+
/// </summary>
24+
public string? RecommendedClusterId { get; set; }
25+
26+
/// <summary>
27+
/// Gets or sets a value indicating whether the recommendation differs
28+
/// from the preferred cluster.
29+
/// </summary>
30+
public bool IsFallback { get; set; }
31+
32+
/// <summary>
33+
/// Gets or sets the ordered list of cluster recommendations, ranked by preference.
34+
/// </summary>
35+
public ClusterRecommendation[] Recommendations { get; set; }
36+
= Array.Empty<ClusterRecommendation>();
37+
}

‎cs/src/Management/ITunnelManagementClient.cs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,25 @@ Task<TunnelAccessSubject[]> ResolveSubjectsAsync(
395395
/// <returns>Array of <see cref="ClusterDetails"/></returns>
396396
Task<ClusterDetails[]> ListClustersAsync(CancellationToken cancellation = default);
397397

398+
/// <summary>
399+
/// Gets cluster recommendations for tunnel creation based on capacity and
400+
/// availability.
401+
/// </summary>
402+
/// <param name="preferredClusterId">
403+
/// Optional preferred cluster ID. When omitted, defaults to the cluster
404+
/// serving the request.
405+
/// </param>
406+
/// <param name="requiredGeo">
407+
/// Optional Azure geography filter. When specified, only clusters in
408+
/// this geo are eligible for recommendation.
409+
/// </param>
410+
/// <param name="cancellation">Cancellation token.</param>
411+
/// <returns>Cluster recommendation response with ranked clusters.</returns>
412+
Task<ClusterRecommendationResponse> GetClusterRecommendationsAsync(
413+
string? preferredClusterId = null,
414+
string? requiredGeo = null,
415+
CancellationToken cancellation = default);
416+
398417
/// <summary>
399418
/// Checks for tunnel name availability.
400419
/// </summary>

‎cs/src/Management/TunnelManagementClient.cs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public class TunnelManagementClient : ITunnelManagementClient
4242
private const string EventsApiSubPath = "/events";
4343
private const string ClustersApiPath = "/clusters";
4444
private const string ClustersV1ApiPath = ApiV1Path + "/clusters";
45+
private const string RecommendationsSubPath = "/recommendations";
4546
private const string TunnelAuthenticationScheme = "Tunnel";
4647
private const string RequestIdHeaderName = "VsSaaS-Request-Id";
4748
private const string CheckAvailableSubPath = ":checkNameAvailability";
@@ -1084,6 +1085,29 @@ public async Task<Tunnel> CreateTunnelAsync(
10841085
{
10851086
Requires.NotNull(tunnel, nameof(tunnel));
10861087
options ??= new TunnelRequestOptions();
1088+
1089+
// If the caller didn't specify a cluster, auto-select one via the
1090+
// recommendations API. Failures fall back to global routing.
1091+
if (string.IsNullOrEmpty(tunnel.ClusterId))
1092+
{
1093+
try
1094+
{
1095+
var recommendations = await GetClusterRecommendationsAsync(
1096+
preferredClusterId: null,
1097+
requiredGeo: options.RequiredGeo,
1098+
cancellation);
1099+
if (!string.IsNullOrEmpty(recommendations?.RecommendedClusterId))
1100+
{
1101+
tunnel.ClusterId = recommendations!.RecommendedClusterId;
1102+
}
1103+
}
1104+
catch (Exception) when (!cancellation.IsCancellationRequested)
1105+
{
1106+
// Fall through to global (Traffic Manager) routing if the
1107+
// recommendations request fails for any reason.
1108+
}
1109+
}
1110+
10871111
options.AdditionalHeaders ??= new List<KeyValuePair<string, string>>();
10881112
options.AdditionalHeaders = options.AdditionalHeaders.Append(
10891113
new KeyValuePair<string, string>("If-None-Match", "*"));
@@ -1595,6 +1619,46 @@ public async Task<ClusterDetails[]> ListClustersAsync(CancellationToken cancella
15951619
return clusterDetails!;
15961620
}
15971621

1622+
/// <inheritdoc/>
1623+
public async Task<ClusterRecommendationResponse> GetClusterRecommendationsAsync(
1624+
string? preferredClusterId = null,
1625+
string? requiredGeo = null,
1626+
CancellationToken cancellation = default)
1627+
{
1628+
var baseAddress = this.httpClient.BaseAddress!;
1629+
var builder = new UriBuilder(baseAddress);
1630+
builder.Path = ClustersPath + RecommendationsSubPath;
1631+
1632+
var queryParts = new List<string>();
1633+
var apiQuery = GetApiQuery();
1634+
if (!string.IsNullOrEmpty(apiQuery))
1635+
{
1636+
queryParts.Add(apiQuery!);
1637+
}
1638+
1639+
if (!string.IsNullOrEmpty(preferredClusterId))
1640+
{
1641+
queryParts.Add(
1642+
$"preferredClusterId={Uri.EscapeDataString(preferredClusterId!)}");
1643+
}
1644+
1645+
if (!string.IsNullOrEmpty(requiredGeo))
1646+
{
1647+
queryParts.Add($"requiredGeo={Uri.EscapeDataString(requiredGeo!)}");
1648+
}
1649+
1650+
builder.Query = string.Join("&", queryParts);
1651+
1652+
var response = await SendRequestAsync<object, ClusterRecommendationResponse>(
1653+
HttpMethod.Get,
1654+
builder.Uri,
1655+
options: null,
1656+
authHeader: null,
1657+
body: null,
1658+
cancellation);
1659+
return response!;
1660+
}
1661+
15981662
/// <inheritdoc/>
15991663
public async Task<bool> CheckNameAvailabilityAsync(
16001664
string name,

‎cs/src/Management/TunnelRequestOptions.cs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,20 @@ public class TunnelRequestOptions
132132
/// </summary>
133133
public uint? Limit { get; set; }
134134

135+
/// <summary>
136+
/// Gets or sets an optional Azure geography filter used when a cluster is
137+
/// automatically recommended during tunnel creation.
138+
/// </summary>
139+
/// <remarks>
140+
/// This option only applies to <see cref="ITunnelManagementClient.CreateTunnelAsync"/>
141+
/// when the tunnel does not already specify a <see cref="Tunnel.ClusterId"/>. In that
142+
/// case the value is forwarded to the cluster recommendations request so that only
143+
/// clusters in the specified geo are eligible for automatic selection. It has no effect
144+
/// when a cluster is explicitly set or on any other request, and it is not sent as part
145+
/// of the create-tunnel request itself.
146+
/// </remarks>
147+
public string? RequiredGeo { get; set; }
148+
135149
/// <summary>
136150
/// Converts tunnel request options to a query string for HTTP requests to the
137151
/// tunnel management API.

‎cs/test/TunnelsSDK.Test/Mocks/MockTunnelManagementClient.cs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,25 @@ public Task<ClusterDetails[]> ListClustersAsync(CancellationToken cancellation =
333333
throw new NotImplementedException();
334334
}
335335

336+
public Task<ClusterRecommendationResponse> GetClusterRecommendationsAsync(
337+
string preferredClusterId = null,
338+
string requiredGeo = null,
339+
CancellationToken cancellation = default)
340+
{
341+
return Task.FromResult(new ClusterRecommendationResponse
342+
{
343+
RecommendedClusterId = "localhost",
344+
Recommendations = new[]
345+
{
346+
new ClusterRecommendation
347+
{
348+
ClusterId = "localhost",
349+
Availability = ClusterAvailability.Available,
350+
},
351+
},
352+
});
353+
}
354+
336355
public Task<bool> CheckNameAvailabilityAsync(string name, CancellationToken cancellation = default)
337356
{
338357
throw new NotImplementedException();

0 commit comments

Comments
 (0)