Skip to content

Commit 363a1e9

Browse files
Merge branch 'main' into docs/rust-reconnection-support
2 parents 45db5c6 + 85c5efe commit 363a1e9

85 files changed

Lines changed: 4052 additions & 648 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,3 +348,5 @@ MigrationBackup/
348348

349349
# Ionide (cross platform F# VS Code tools) working folder
350350
.ionide/
351+
352+
*.lscache

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,7 @@
33
"ts"
44
],
55
"rust-analyzer.cargo.features": ["connections", "vendored-openssl"],
6+
"rust-analyzer.linkedProjects": [
7+
"rs/Cargo.toml"
8+
],
69
}

cs/build/build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
<NerdbankStreamsVersion>2.13.16</NerdbankStreamsVersion>
4242
<ReportGeneratorVersion>5.5.4</ReportGeneratorVersion>
4343
<VisualStudioValidationVersion>17.13.22</VisualStudioValidationVersion>
44-
<DevTunnelsSshPackageVersion>3.12.24</DevTunnelsSshPackageVersion>
44+
<DevTunnelsSshPackageVersion>3.12.36</DevTunnelsSshPackageVersion>
4545
<XunitRunnerVisualStudioVersion>3.1.5</XunitRunnerVisualStudioVersion>
4646
<XunitV3Version>3.2.2</XunitV3Version>
4747
<XunitV3ExtensibilityCoreVersion>3.2.2</XunitV3ExtensibilityCoreVersion>
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/Contracts/DevTunnels.Contracts.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<RootNamespace>Microsoft.DevTunnels.Contracts</RootNamespace>

cs/src/Contracts/TunnelServiceProperties.cs

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ public class TunnelServiceProperties
2727
/// </summary>
2828
internal const string DevDnsName = "global.ci.tunnels.dev.api.visualstudio.com";
2929

30+
/// <summary>
31+
/// Default host name for the local tunnel service.
32+
/// </summary>
33+
internal const string LocalDnsName = "global.tunnels.local.api.visualstudio.com";
34+
3035
/// <summary>
3136
/// First-party app ID: `Visual Studio Tunnel Service`
3237
/// </summary>
@@ -46,14 +51,14 @@ public class TunnelServiceProperties
4651
internal const string PpeFirstPartyAppId = "54c45752-bacd-424a-b928-652f3eca2b18";
4752

4853
/// <summary>
49-
/// Third-party app ID: `DEV-VSTunnelService-3P`
54+
/// Third-party app ID for the DEV service environment.
5055
/// </summary>
5156
/// <remarks>
52-
/// Used for authenticating AAD/MSA users, and service principals outside the AME tenant,
53-
/// in the DEV service environment.
54-
/// This is a 3P app registration in the Microsoft corp tenant, replacing the former 1P FPA.
57+
/// A 3PA registered in the test tenant, used for authenticating AAD/MSA users
58+
/// and service principals outside the AME tenant. Replaces the former 1P FPA
59+
/// (9c63851a) which was disabled.
5560
/// </remarks>
56-
internal const string DevFirstPartyAppId = "906ce216-6f2e-40be-875d-7fe1a9bc288a";
61+
internal const string DevServiceAppId = "686ab1c6-3cd9-4a62-b228-4f71eaaaf53e";
5762

5863
/// <summary>
5964
/// Third-party app ID: `tunnels-prod-app-sp`
@@ -95,10 +100,28 @@ public class TunnelServiceProperties
95100
/// GitHub App Client ID for 'Visual Studio Tunnel Service - Test'
96101
/// </summary>
97102
/// <remarks>
98-
/// Used by client apps that authenticate tunnel users with GitHub, in the PPE and DEV
99-
/// service environments.
103+
/// Used by client apps that authenticate tunnel users with GitHub, in the PPE
104+
/// service environment.
105+
/// </remarks>
106+
internal const string PpeGitHubAppClientId = "Iv1.b231c327f1eaa229";
107+
108+
/// <summary>
109+
/// GitHub App Client ID for 'Dev Tunnels Service - Dev'
110+
/// </summary>
111+
/// <remarks>
112+
/// Used by client apps that authenticate tunnel users with GitHub, in the DEV
113+
/// service environment.
100114
/// </remarks>
101-
internal const string NonProdGitHubAppClientId = "Iv1.b231c327f1eaa229";
115+
internal const string DevGitHubAppClientId = "Iv23ctTiak9wLCiTcEbr";
116+
117+
/// <summary>
118+
/// GitHub App Client ID for 'Dev Tunnels Service - Local'
119+
/// </summary>
120+
/// <remarks>
121+
/// Used by client apps that authenticate tunnel users with GitHub, when running
122+
/// the service locally.
123+
/// </remarks>
124+
internal const string LocalGitHubAppClientId = "Iv23cttBYzKThF88PiPR";
102125

103126
private TunnelServiceProperties(
104127
string serviceUri,
@@ -128,16 +151,29 @@ private TunnelServiceProperties(
128151
$"https://{PpeDnsName}/",
129152
PpeFirstPartyAppId,
130153
PpeThirdPartyAppId,
131-
NonProdGitHubAppClientId);
154+
PpeGitHubAppClientId);
132155

133156
/// <summary>
134157
/// Gets properties for the service in the development environment.
135158
/// </summary>
136159
public static TunnelServiceProperties Development { get; } = new TunnelServiceProperties(
137160
$"https://{DevDnsName}/",
138-
DevFirstPartyAppId,
161+
DevServiceAppId,
162+
DevThirdPartyAppId,
163+
DevGitHubAppClientId);
164+
165+
/// <summary>
166+
/// Gets properties for the service when running locally.
167+
/// </summary>
168+
/// <remarks>
169+
/// Uses the same service app IDs as the development environment, but a different
170+
/// GitHub app with localhost callback URLs.
171+
/// </remarks>
172+
public static TunnelServiceProperties Local { get; } = new TunnelServiceProperties(
173+
$"https://{LocalDnsName}/",
174+
DevServiceAppId,
139175
DevThirdPartyAppId,
140-
NonProdGitHubAppClientId);
176+
LocalGitHubAppClientId);
141177

142178
/// <summary>
143179
/// Gets properties for the service in the specified environment.
@@ -157,6 +193,7 @@ public static TunnelServiceProperties Environment(string environmentName)
157193
"prod" or "production" => TunnelServiceProperties.Production,
158194
"ppe" or "preprod" or "staging" => TunnelServiceProperties.Staging,
159195
"dev" or "development" => TunnelServiceProperties.Development,
196+
"local" => TunnelServiceProperties.Local,
160197
_ => throw new ArgumentException($"Invalid service environment: {environmentName}"),
161198
};
162199
}

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,

0 commit comments

Comments
 (0)