diff --git a/cs/src/Contracts/ClusterAvailability.cs b/cs/src/Contracts/ClusterAvailability.cs new file mode 100644 index 00000000..ee993a85 --- /dev/null +++ b/cs/src/Contracts/ClusterAvailability.cs @@ -0,0 +1,27 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// + +namespace Microsoft.DevTunnels.Contracts; + +/// +/// Availability status of a tunneling service cluster. +/// +public enum ClusterAvailability +{ + /// + /// Cluster has sufficient capacity and is fully available. + /// + Available, + + /// + /// Cluster is approaching capacity limits and may experience delays. + /// + Degraded, + + /// + /// Cluster is at or beyond capacity and should not be used for new tunnels. + /// + Unavailable, +} diff --git a/cs/src/Contracts/ClusterRecommendation.cs b/cs/src/Contracts/ClusterRecommendation.cs new file mode 100644 index 00000000..965fbe6a --- /dev/null +++ b/cs/src/Contracts/ClusterRecommendation.cs @@ -0,0 +1,47 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// + +namespace Microsoft.DevTunnels.Contracts; + +/// +/// A single cluster recommendation with availability and capacity details. +/// +public class ClusterRecommendation +{ + /// + /// Gets or sets the cluster ID, e.g. "usw2". + /// + public string ClusterId { get; set; } = null!; + + /// + /// Gets or sets the Azure location name, e.g. "WestUs2". + /// + public string AzureLocation { get; set; } = null!; + + /// + /// Gets or sets the Azure geography name for data residency, e.g. "United States". + /// + public string AzureGeo { get; set; } = null!; + + /// + /// Gets or sets the cluster URI for API requests. + /// + public string ClusterUri { get; set; } = null!; + + /// + /// Gets or sets the availability status of the cluster. + /// + public ClusterAvailability Availability { get; set; } + + /// + /// Gets or sets the utilization percentage of the cluster. + /// + public double UtilizationPercent { get; set; } + + /// + /// Gets or sets a human-readable reason for this recommendation's ranking. + /// + public string Reason { get; set; } = null!; +} diff --git a/cs/src/Contracts/ClusterRecommendationResponse.cs b/cs/src/Contracts/ClusterRecommendationResponse.cs new file mode 100644 index 00000000..2becfa42 --- /dev/null +++ b/cs/src/Contracts/ClusterRecommendationResponse.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// + +using System; + +namespace Microsoft.DevTunnels.Contracts; + +/// +/// Response from the cluster recommendation API containing ranked cluster recommendations. +/// +public class ClusterRecommendationResponse +{ + /// + /// Gets or sets the preferred cluster ID that was requested, if any. + /// + public string? PreferredClusterId { get; set; } + + /// + /// Gets or sets the recommended cluster ID — the best available cluster. + /// Null if no clusters are available. + /// + public string? RecommendedClusterId { get; set; } + + /// + /// Gets or sets a value indicating whether the recommendation differs + /// from the preferred cluster. + /// + public bool IsFallback { get; set; } + + /// + /// Gets or sets the ordered list of cluster recommendations, ranked by preference. + /// + public ClusterRecommendation[] Recommendations { get; set; } + = Array.Empty(); +} diff --git a/cs/src/Management/ITunnelManagementClient.cs b/cs/src/Management/ITunnelManagementClient.cs index 6bfc6b3f..5410a4f1 100644 --- a/cs/src/Management/ITunnelManagementClient.cs +++ b/cs/src/Management/ITunnelManagementClient.cs @@ -395,6 +395,25 @@ Task ResolveSubjectsAsync( /// Array of Task ListClustersAsync(CancellationToken cancellation = default); + /// + /// Gets cluster recommendations for tunnel creation based on capacity and + /// availability. + /// + /// + /// Optional preferred cluster ID. When omitted, defaults to the cluster + /// serving the request. + /// + /// + /// Optional Azure geography filter. When specified, only clusters in + /// this geo are eligible for recommendation. + /// + /// Cancellation token. + /// Cluster recommendation response with ranked clusters. + Task GetClusterRecommendationsAsync( + string? preferredClusterId = null, + string? requiredGeo = null, + CancellationToken cancellation = default); + /// /// Checks for tunnel name availability. /// diff --git a/cs/src/Management/TunnelManagementClient.cs b/cs/src/Management/TunnelManagementClient.cs index 47ce73e5..8ce2694f 100644 --- a/cs/src/Management/TunnelManagementClient.cs +++ b/cs/src/Management/TunnelManagementClient.cs @@ -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"; @@ -1084,6 +1085,29 @@ public async Task 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>(); options.AdditionalHeaders = options.AdditionalHeaders.Append( new KeyValuePair("If-None-Match", "*")); @@ -1595,6 +1619,46 @@ public async Task ListClustersAsync(CancellationToken cancella return clusterDetails!; } + /// + public async Task 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(); + 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( + HttpMethod.Get, + builder.Uri, + options: null, + authHeader: null, + body: null, + cancellation); + return response!; + } + /// public async Task CheckNameAvailabilityAsync( string name, diff --git a/cs/src/Management/TunnelRequestOptions.cs b/cs/src/Management/TunnelRequestOptions.cs index e092ed29..69d76ff5 100644 --- a/cs/src/Management/TunnelRequestOptions.cs +++ b/cs/src/Management/TunnelRequestOptions.cs @@ -132,6 +132,20 @@ public class TunnelRequestOptions /// public uint? Limit { get; set; } + /// + /// Gets or sets an optional Azure geography filter used when a cluster is + /// automatically recommended during tunnel creation. + /// + /// + /// This option only applies to + /// when the tunnel does not already specify a . 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. + /// + public string? RequiredGeo { get; set; } + /// /// Converts tunnel request options to a query string for HTTP requests to the /// tunnel management API. diff --git a/cs/test/TunnelsSDK.Test/Mocks/MockTunnelManagementClient.cs b/cs/test/TunnelsSDK.Test/Mocks/MockTunnelManagementClient.cs index ee55fba7..2532518c 100644 --- a/cs/test/TunnelsSDK.Test/Mocks/MockTunnelManagementClient.cs +++ b/cs/test/TunnelsSDK.Test/Mocks/MockTunnelManagementClient.cs @@ -333,6 +333,25 @@ public Task ListClustersAsync(CancellationToken cancellation = throw new NotImplementedException(); } + public Task 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 CheckNameAvailabilityAsync(string name, CancellationToken cancellation = default) { throw new NotImplementedException(); diff --git a/cs/test/TunnelsSDK.Test/TunnelManagementClientTests.cs b/cs/test/TunnelsSDK.Test/TunnelManagementClientTests.cs index 7e361f7b..bf7fdf4e 100644 --- a/cs/test/TunnelsSDK.Test/TunnelManagementClientTests.cs +++ b/cs/test/TunnelsSDK.Test/TunnelManagementClientTests.cs @@ -322,6 +322,319 @@ public async Task StandardServiceUriReplacesClusterIdInHostname() } + [Fact] + public async Task GetClusterRecommendationsAsync_ReturnsDeserializedResponse() + { + const string responseJson = @"{ + ""preferredClusterId"": ""usw2"", + ""recommendedClusterId"": ""usw4"", + ""isFallback"": true, + ""recommendations"": [ + { + ""clusterId"": ""usw4"", + ""azureLocation"": ""WestUs2"", + ""azureGeo"": ""United States"", + ""clusterUri"": ""https://usw4.ci.tunnels.dev.api.visualstudio.com"", + ""availability"": ""Available"", + ""utilizationPercent"": 12.5, + ""reason"": ""Preferred cluster available"" + }, + { + ""clusterId"": ""usw2"", + ""azureLocation"": ""WestUs2"", + ""azureGeo"": ""United States"", + ""clusterUri"": ""https://usw2.ci.tunnels.dev.api.visualstudio.com"", + ""availability"": ""Degraded"", + ""utilizationPercent"": 87.0, + ""reason"": ""Near capacity"" + } + ] + }"; + + Uri capturedUri = null; + var handler = new MockHttpMessageHandler( + (message, ct) => + { + capturedUri = message.RequestUri; + var result = new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = message, + Content = new StringContent( + responseJson, System.Text.Encoding.UTF8, "application/json"), + }; + return Task.FromResult(result); + }); + + var client = new TunnelManagementClient(this.userAgent, null, this.tunnelServiceUri, handler); + + var response = await client.GetClusterRecommendationsAsync(cancellation: this.timeout); + + Assert.NotNull(response); + Assert.Equal("usw2", response.PreferredClusterId); + Assert.Equal("usw4", response.RecommendedClusterId); + Assert.True(response.IsFallback); + Assert.Equal(2, response.Recommendations.Length); + Assert.Equal("usw4", response.Recommendations[0].ClusterId); + Assert.Equal(ClusterAvailability.Available, response.Recommendations[0].Availability); + Assert.Equal(12.5, response.Recommendations[0].UtilizationPercent); + Assert.Equal(ClusterAvailability.Degraded, response.Recommendations[1].Availability); + Assert.NotNull(capturedUri); + Assert.Contains("/clusters/recommendations", capturedUri.AbsolutePath); + } + + [Fact] + public async Task GetClusterRecommendationsAsync_PassesQueryParameters() + { + Uri capturedUri = null; + var handler = new MockHttpMessageHandler( + (message, ct) => + { + capturedUri = message.RequestUri; + var result = new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = message, + Content = new StringContent( + "{\"recommendations\":[]}", + System.Text.Encoding.UTF8, + "application/json"), + }; + return Task.FromResult(result); + }); + + var client = new TunnelManagementClient(this.userAgent, null, this.tunnelServiceUri, handler); + + await client.GetClusterRecommendationsAsync( + preferredClusterId: "usw2", requiredGeo: "us", cancellation: this.timeout); + + Assert.NotNull(capturedUri); + Assert.Contains("preferredClusterId=usw2", capturedUri.Query); + Assert.Contains("requiredGeo=us", capturedUri.Query); + } + + [Fact] + public async Task CreateTunnelAsync_AutoRecommendsWhenClusterIdNotSet() + { + var requestTunnel = new Tunnel + { + TunnelId = TunnelId, + + // ClusterId intentionally not set, so the client auto-recommends. + }; + + var recommendationCalls = 0; + var createCalls = 0; + Uri createUri = null; + + var handler = new MockHttpMessageHandler( + async (message, ct) => + { + if (message.RequestUri!.AbsolutePath.EndsWith("/recommendations")) + { + recommendationCalls++; + var recResult = new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = message, + Content = new StringContent( + "{\"recommendedClusterId\":\"usw4\",\"recommendations\":[]}", + System.Text.Encoding.UTF8, + "application/json"), + }; + return recResult; + } + + createCalls++; + createUri = message.RequestUri; + var sentTunnel = await message.Content!.ReadFromJsonAsync( + cancellationToken: ct); + var responseTunnel = new Tunnel + { + TunnelId = sentTunnel!.TunnelId, + ClusterId = "usw4", + }; + var result = new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = message, + Content = JsonContent.Create(responseTunnel), + }; + return result; + }); + + var client = new TunnelManagementClient( + this.userAgent, + tunnelServiceUri: new Uri("https://global.rel.tunnels.api.visualstudio.com/"), + httpHandler: handler); + + var resultTunnel = await client.CreateTunnelAsync( + requestTunnel, options: null, this.timeout); + + Assert.Equal(1, recommendationCalls); + Assert.Equal(1, createCalls); + Assert.Equal("usw4", requestTunnel.ClusterId); + Assert.NotNull(createUri); + Assert.StartsWith("usw4.", createUri.Host); + Assert.NotNull(resultTunnel); + } + + [Fact] + public async Task CreateTunnelAsync_ForwardsRequiredGeoFromOptionsToRecommendation() + { + var requestTunnel = new Tunnel + { + TunnelId = TunnelId, + + // ClusterId intentionally not set, so the client auto-recommends. + }; + + Uri recommendationUri = null; + Uri createUri = null; + + var handler = new MockHttpMessageHandler( + async (message, ct) => + { + if (message.RequestUri!.AbsolutePath.EndsWith("/recommendations")) + { + recommendationUri = message.RequestUri; + return new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = message, + Content = new StringContent( + "{\"recommendedClusterId\":\"usw4\",\"recommendations\":[]}", + System.Text.Encoding.UTF8, + "application/json"), + }; + } + + createUri = message.RequestUri; + var sentTunnel = await message.Content!.ReadFromJsonAsync( + cancellationToken: ct); + var responseTunnel = new Tunnel + { + TunnelId = sentTunnel!.TunnelId, + ClusterId = "usw4", + }; + return new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = message, + Content = JsonContent.Create(responseTunnel), + }; + }); + + var client = new TunnelManagementClient( + this.userAgent, + tunnelServiceUri: new Uri("https://global.rel.tunnels.api.visualstudio.com/"), + httpHandler: handler); + + var options = new TunnelRequestOptions { RequiredGeo = "us" }; + await client.CreateTunnelAsync(requestTunnel, options, this.timeout); + + // requiredGeo flows to the recommendations request... + Assert.NotNull(recommendationUri); + Assert.Contains("requiredGeo=us", recommendationUri.Query); + + // ...but is NOT included on the create-tunnel request itself. + Assert.NotNull(createUri); + Assert.DoesNotContain("requiredGeo", createUri.Query); + } + + [Fact] + public async Task CreateTunnelAsync_SkipsRecommendWhenClusterIdSet() + { + var requestTunnel = new Tunnel + { + TunnelId = TunnelId, + ClusterId = "usw2", + }; + + var recommendationCalls = 0; + var createCalls = 0; + Uri createUri = null; + + var handler = new MockHttpMessageHandler( + (message, ct) => + { + if (message.RequestUri!.AbsolutePath.EndsWith("/recommendations")) + { + recommendationCalls++; + } + else + { + createCalls++; + createUri = message.RequestUri; + } + + var result = new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = message, + Content = JsonContent.Create(requestTunnel), + }; + return Task.FromResult(result); + }); + + var client = new TunnelManagementClient( + this.userAgent, + tunnelServiceUri: new Uri("https://global.rel.tunnels.api.visualstudio.com/"), + httpHandler: handler); + + await client.CreateTunnelAsync(requestTunnel, options: null, this.timeout); + + Assert.Equal(0, recommendationCalls); + Assert.Equal(1, createCalls); + Assert.NotNull(createUri); + Assert.StartsWith("usw2.", createUri.Host); + } + + [Fact] + public async Task CreateTunnelAsync_FallsBackOnRecommendFailure() + { + var requestTunnel = new Tunnel + { + TunnelId = TunnelId, + + // ClusterId not set; recommendations call will fail. + }; + + var createCalls = 0; + Uri createUri = null; + + var handler = new MockHttpMessageHandler( + (message, ct) => + { + if (message.RequestUri!.AbsolutePath.EndsWith("/recommendations")) + { + var failure = new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + RequestMessage = message, + }; + return Task.FromResult(failure); + } + + createCalls++; + createUri = message.RequestUri; + var result = new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = message, + Content = JsonContent.Create(requestTunnel), + }; + return Task.FromResult(result); + }); + + var client = new TunnelManagementClient( + this.userAgent, + tunnelServiceUri: new Uri("https://global.rel.tunnels.api.visualstudio.com/"), + httpHandler: handler); + + var resultTunnel = await client.CreateTunnelAsync( + requestTunnel, options: null, this.timeout); + + Assert.Equal(1, createCalls); + Assert.Null(requestTunnel.ClusterId); + Assert.NotNull(createUri); + + // No cluster prefix was added: routing falls back to the global hostname. + Assert.Equal("global.rel.tunnels.api.visualstudio.com", createUri.Host); + Assert.NotNull(resultTunnel); + } + private sealed class MockHttpMessageHandler : DelegatingHandler { private readonly Func> handler; diff --git a/cs/tools/TunnelsSDK.Generator/GoContractWriter.cs b/cs/tools/TunnelsSDK.Generator/GoContractWriter.cs index a583d5b0..9042e4b3 100644 --- a/cs/tools/TunnelsSDK.Generator/GoContractWriter.cs +++ b/cs/tools/TunnelsSDK.Generator/GoContractWriter.cs @@ -447,6 +447,8 @@ private string GetGoTypeForCSType(string csType, IPropertySymbol property, Sorte "uint" => "uint32", "long" => "int64", "ulong" => "uint64", + "float" => "float32", + "double" => "float64", "string" => "string", "System.DateTime" => "time.Time", "System.Text.RegularExpressions.Regex" => "regexp.Regexp", diff --git a/cs/tools/TunnelsSDK.Generator/JavaContractWriter.cs b/cs/tools/TunnelsSDK.Generator/JavaContractWriter.cs index a21389da..d20bcdeb 100644 --- a/cs/tools/TunnelsSDK.Generator/JavaContractWriter.cs +++ b/cs/tools/TunnelsSDK.Generator/JavaContractWriter.cs @@ -436,6 +436,8 @@ private string GetJavaTypeForCSType(string csType, string propertyName, SortedSe "uint" => "int", "long" => "long", "ulong" => "long", + "float" => "float", + "double" => "double", "string" => "String", "System.DateTime" => JavaDateTimeType, "System.Text.RegularExpressions.Regex" => RegexPatternType, diff --git a/cs/tools/TunnelsSDK.Generator/RustContractWriter.cs b/cs/tools/TunnelsSDK.Generator/RustContractWriter.cs index 1dcc2dba..9d5a4869 100644 --- a/cs/tools/TunnelsSDK.Generator/RustContractWriter.cs +++ b/cs/tools/TunnelsSDK.Generator/RustContractWriter.cs @@ -511,6 +511,8 @@ private void AppendStructProperty(ITypeSymbol parentType, IPropertySymbol proper "uint" => "u32", "long" => "i64", "ulong" => "u64", + "float" => "f32", + "double" => "f64", "string" => "String", "System.DateTime" => "Timestamp", "System.Text.RegularExpressions.Regex" => "regexp.Regexp", diff --git a/cs/tools/TunnelsSDK.Generator/TSContractWriter.cs b/cs/tools/TunnelsSDK.Generator/TSContractWriter.cs index 41b88cd7..7d14e3c0 100644 --- a/cs/tools/TunnelsSDK.Generator/TSContractWriter.cs +++ b/cs/tools/TunnelsSDK.Generator/TSContractWriter.cs @@ -413,6 +413,8 @@ private string GetTSTypeForCSType(string csType, string propertyName, SortedSet< "uint" => "number", "long" => "number", "ulong" => "number", + "float" => "number", + "double" => "number", "string" => "string", "System.DateTime" => "Date", "System.Text.RegularExpressions.Regex" => "RegExp", diff --git a/go/tunnels/cluster_availability.go b/go/tunnels/cluster_availability.go new file mode 100644 index 00000000..8d3e9d0e --- /dev/null +++ b/go/tunnels/cluster_availability.go @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// Generated from ../../../cs/src/Contracts/ClusterAvailability.cs + +package tunnels + +// Availability status of a tunneling service cluster. +type ClusterAvailability string + +const ( + // Cluster has sufficient capacity and is fully available. + ClusterAvailabilityAvailable ClusterAvailability = "Available" + + // Cluster is approaching capacity limits and may experience delays. + ClusterAvailabilityDegraded ClusterAvailability = "Degraded" + + // Cluster is at or beyond capacity and should not be used for new tunnels. + ClusterAvailabilityUnavailable ClusterAvailability = "Unavailable" +) diff --git a/go/tunnels/cluster_recommendation.go b/go/tunnels/cluster_recommendation.go new file mode 100644 index 00000000..b2fa345a --- /dev/null +++ b/go/tunnels/cluster_recommendation.go @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// Generated from ../../../cs/src/Contracts/ClusterRecommendation.cs + +package tunnels + +// A single cluster recommendation with availability and capacity details. +type ClusterRecommendation struct { + // Gets or sets the cluster ID, e.g. "usw2". + ClusterID string `json:"clusterId"` + + // Gets or sets the Azure location name, e.g. "WestUs2". + AzureLocation string `json:"azureLocation"` + + // Gets or sets the Azure geography name for data residency, e.g. "United States". + AzureGeo string `json:"azureGeo"` + + // Gets or sets the cluster URI for API requests. + ClusterURI string `json:"clusterUri"` + + // Gets or sets the availability status of the cluster. + Availability ClusterAvailability `json:"availability"` + + // Gets or sets the utilization percentage of the cluster. + UtilizationPercent float64 `json:"utilizationPercent"` + + // Gets or sets a human-readable reason for this recommendation's ranking. + Reason string `json:"reason"` +} diff --git a/go/tunnels/cluster_recommendation_response.go b/go/tunnels/cluster_recommendation_response.go new file mode 100644 index 00000000..ea936736 --- /dev/null +++ b/go/tunnels/cluster_recommendation_response.go @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// Generated from ../../../cs/src/Contracts/ClusterRecommendationResponse.cs + +package tunnels + +// Response from the cluster recommendation API containing ranked cluster recommendations. +type ClusterRecommendationResponse struct { + // Gets or sets the preferred cluster ID that was requested, if any. + PreferredClusterID string `json:"preferredClusterId"` + + // Gets or sets the recommended cluster ID — the best available cluster. Null if no + // clusters are available. + RecommendedClusterID string `json:"recommendedClusterId"` + + // Gets or sets a value indicating whether the recommendation differs from the preferred + // cluster. + IsFallback bool `json:"isFallback"` + + // Gets or sets the ordered list of cluster recommendations, ranked by preference. + Recommendations []ClusterRecommendation `json:"recommendations"` +} diff --git a/go/tunnels/manager.go b/go/tunnels/manager.go index 282e7fff..808281df 100644 --- a/go/tunnels/manager.go +++ b/go/tunnels/manager.go @@ -54,6 +54,7 @@ const ( userLimitsApiPath = "/userlimits" subjectsApiPath = "/subjects" clustersApiPath = "/clusters" + recommendationsApiSubPath = "/recommendations" checkNameAvailabilityPath = ":checkNameAvailability" endpointsApiSubPath = "/endpoints" portsApiSubPath = "/ports" @@ -230,6 +231,15 @@ func (m *Manager) CreateTunnel(ctx context.Context, tunnel *Tunnel, options *Tun } options.AdditionalHeaders["If-Not-Match"] = "*" + // If the caller didn't specify a cluster, auto-select one via the + // recommendations API. Failures fall back to global routing. + if tunnel.ClusterID == "" { + recommendations, recErr := m.GetClusterRecommendations(ctx, "", options.RequiredGeo) + if recErr == nil && recommendations != nil && recommendations.RecommendedClusterID != "" { + tunnel.ClusterID = recommendations.RecommendedClusterID + } + } + convertedTunnel, err := tunnel.requestObject() convertedTunnel.TunnelID = tunnel.TunnelID if err != nil { @@ -705,6 +715,35 @@ func (m *Manager) ListClusters(ctx context.Context) (clusters []*ClusterDetails, return clusters, nil } +// Gets cluster recommendations for tunnel creation based on capacity and availability. +// preferredClusterId and requiredGeo are optional; pass an empty string to omit them. +func (m *Manager) GetClusterRecommendations( + ctx context.Context, preferredClusterId string, requiredGeo string, +) (recommendations *ClusterRecommendationResponse, err error) { + queryValues := url.Values{} + if preferredClusterId != "" { + queryValues.Set("preferredClusterId", preferredClusterId) + } + if requiredGeo != "" { + queryValues.Set("requiredGeo", requiredGeo) + } + + path := clustersApiPath + recommendationsApiSubPath + url := m.buildUri("", path, nil, queryValues.Encode()) + response, err := m.sendRequest(ctx, http.MethodGet, url, nil, nil, "", false) + + if err != nil { + return nil, fmt.Errorf("error getting cluster recommendations: %w", err) + } + + err = json.Unmarshal(response, &recommendations) + if err != nil { + return nil, fmt.Errorf("error parsing response json to ClusterRecommendationResponse: %w", err) + } + + return recommendations, nil +} + // Checks if tunnel name is available // Returns true if name is available func (m *Manager) CheckNameAvailability( @@ -894,7 +933,10 @@ func (m *Manager) getAccessToken(tunnel *Tunnel, tunnelRequestOptions *TunnelReq } func (m *Manager) buildUri(clusterId string, path string, options *TunnelRequestOptions, query string) *url.URL { - baseAddress := m.uri + // Copy the URL by value so that mutations below (Host, Path, RawQuery) do + // not corrupt the shared manager URI (m.uri is a pointer). + baseAddressValue := *m.uri + baseAddress := &baseAddressValue if clusterId != "" && !m.isCustomDomain { // tunnels.local.api.visualstudio.com resolves to localhost (for local development). if baseAddress.Host != "localhost" && diff --git a/go/tunnels/manager_test.go b/go/tunnels/manager_test.go index eb9dc40e..0a537419 100644 --- a/go/tunnels/manager_test.go +++ b/go/tunnels/manager_test.go @@ -128,6 +128,9 @@ func TestCreateTunnelRetriesOnConflictForGeneratedID(t *testing.T) { var bodyIDs []string callCount := 0 client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + if strings.Contains(r.URL.Path, "recommendations") { + return responseWithStatus(http.StatusOK, "{}"), nil + } callCount++ pathIDs = append(pathIDs, tunnelIDFromPath(r.URL.Path)) bodyBytes, readErr := io.ReadAll(r.Body) @@ -185,6 +188,9 @@ func TestCreateTunnelDoesNotRetryOnNonConflict(t *testing.T) { callCount := 0 client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + if strings.Contains(r.URL.Path, "recommendations") { + return responseWithStatus(http.StatusOK, "{}"), nil + } callCount++ return responseWithStatus(http.StatusInternalServerError, ""), nil })} @@ -211,6 +217,9 @@ func TestCreateTunnelDoesNotRetryWhenIDProvided(t *testing.T) { callCount := 0 client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + if strings.Contains(r.URL.Path, "recommendations") { + return responseWithStatus(http.StatusOK, "{}"), nil + } callCount++ return responseWithStatus(http.StatusConflict, ""), nil })} @@ -229,6 +238,166 @@ func TestCreateTunnelDoesNotRetryWhenIDProvided(t *testing.T) { } } +func TestCreateTunnelAutoRecommendsWhenClusterIDNotSet(t *testing.T) { + url, err := url.Parse("https://example.test/") + if err != nil { + t.Fatalf("error parsing url: %v", err) + } + + recommendationCalls := 0 + createCalls := 0 + client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + if strings.Contains(r.URL.Path, "recommendations") { + recommendationCalls++ + return responseWithStatus(http.StatusOK, "{\"recommendedClusterId\":\"usw4\",\"recommendations\":[]}"), nil + } + createCalls++ + id := tunnelIDFromPath(r.URL.Path) + body := fmt.Sprintf("{\"tunnelId\":\"%s\",\"clusterId\":\"usw4\"}", id) + return responseWithStatus(http.StatusOK, body), nil + })} + + managementClient, err := NewManager(userAgentManagerTest, getUserToken, url, client, "2023-09-27-preview") + if err != nil { + t.Fatalf("error creating manager: %v", err) + } + + tunnel := &Tunnel{} + createdTunnel, err := managementClient.CreateTunnel(context.Background(), tunnel, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if recommendationCalls != 1 { + t.Fatalf("expected 1 recommendation request, got %d", recommendationCalls) + } + if createCalls != 1 { + t.Fatalf("expected 1 create request, got %d", createCalls) + } + if tunnel.ClusterID != "usw4" { + t.Fatalf("expected tunnel ClusterID to be set to usw4, got %q", tunnel.ClusterID) + } + if createdTunnel.ClusterID != "usw4" { + t.Fatalf("expected created tunnel ClusterID usw4, got %q", createdTunnel.ClusterID) + } +} + +func TestCreateTunnelForwardsRequiredGeoFromOptionsToRecommendation(t *testing.T) { + url, err := url.Parse("https://example.test/") + if err != nil { + t.Fatalf("error parsing url: %v", err) + } + + recommendationQuery := "" + createQuery := "" + client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + if strings.Contains(r.URL.Path, "recommendations") { + recommendationQuery = r.URL.RawQuery + return responseWithStatus(http.StatusOK, "{\"recommendedClusterId\":\"usw4\",\"recommendations\":[]}"), nil + } + createQuery = r.URL.RawQuery + id := tunnelIDFromPath(r.URL.Path) + body := fmt.Sprintf("{\"tunnelId\":\"%s\",\"clusterId\":\"usw4\"}", id) + return responseWithStatus(http.StatusOK, body), nil + })} + + managementClient, err := NewManager(userAgentManagerTest, getUserToken, url, client, "2023-09-27-preview") + if err != nil { + t.Fatalf("error creating manager: %v", err) + } + + options := &TunnelRequestOptions{RequiredGeo: "us"} + _, err = managementClient.CreateTunnel(context.Background(), &Tunnel{}, options) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // requiredGeo flows to the recommendations request... + if !strings.Contains(recommendationQuery, "requiredGeo=us") { + t.Fatalf("expected recommendations query to contain requiredGeo=us, got %q", recommendationQuery) + } + // ...but is NOT included on the create-tunnel request itself. + if strings.Contains(createQuery, "requiredGeo") { + t.Fatalf("expected create query to NOT contain requiredGeo, got %q", createQuery) + } +} + +func TestCreateTunnelSkipsRecommendWhenClusterIDSet(t *testing.T) { + url, err := url.Parse("https://example.test/") + if err != nil { + t.Fatalf("error parsing url: %v", err) + } + + recommendationCalls := 0 + createCalls := 0 + client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + if strings.Contains(r.URL.Path, "recommendations") { + recommendationCalls++ + return responseWithStatus(http.StatusOK, "{}"), nil + } + createCalls++ + id := tunnelIDFromPath(r.URL.Path) + body := fmt.Sprintf("{\"tunnelId\":\"%s\",\"clusterId\":\"usw2\"}", id) + return responseWithStatus(http.StatusOK, body), nil + })} + + managementClient, err := NewManager(userAgentManagerTest, getUserToken, url, client, "2023-09-27-preview") + if err != nil { + t.Fatalf("error creating manager: %v", err) + } + + _, err = managementClient.CreateTunnel(context.Background(), &Tunnel{ClusterID: "usw2"}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if recommendationCalls != 0 { + t.Fatalf("expected 0 recommendation requests, got %d", recommendationCalls) + } + if createCalls != 1 { + t.Fatalf("expected 1 create request, got %d", createCalls) + } +} + +// Regression test: auto-recommend sets tunnel.ClusterID, which routes the +// create request through buildUri. buildUri must NOT mutate the shared +// manager base URI (m.uri); otherwise subsequent requests would be pinned to +// the recommended cluster's host. +func TestCreateTunnelDoesNotMutateManagerBaseURI(t *testing.T) { + url, err := url.Parse("https://example.test/") + if err != nil { + t.Fatalf("error parsing url: %v", err) + } + + createHost := "" + client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + if strings.Contains(r.URL.Path, "recommendations") { + return responseWithStatus(http.StatusOK, "{\"recommendedClusterId\":\"usw4\",\"recommendations\":[]}"), nil + } + createHost = r.URL.Host + id := tunnelIDFromPath(r.URL.Path) + body := fmt.Sprintf("{\"tunnelId\":\"%s\",\"clusterId\":\"usw4\"}", id) + return responseWithStatus(http.StatusOK, body), nil + })} + + managementClient, err := NewManager(userAgentManagerTest, getUserToken, url, client, "2023-09-27-preview") + if err != nil { + t.Fatalf("error creating manager: %v", err) + } + + _, err = managementClient.CreateTunnel(context.Background(), &Tunnel{}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // The create request should have been routed to the cluster-prefixed host. + if createHost != "usw4.example.test" { + t.Fatalf("expected create request host usw4.example.test, got %q", createHost) + } + // But the shared manager base URI must remain unchanged. + if managementClient.uri.Host != "example.test" { + t.Fatalf("expected manager base URI host to remain example.test, got %q", managementClient.uri.Host) + } +} + func TestListTunnels(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode.") diff --git a/go/tunnels/request_options.go b/go/tunnels/request_options.go index cdcf47fe..4882d179 100644 --- a/go/tunnels/request_options.go +++ b/go/tunnels/request_options.go @@ -45,6 +45,16 @@ type TunnelRequestOptions struct { // Limits the number of tunnels returned when searching or listing tunnels. Limit uint + + // Optional Azure geography filter used when a cluster is automatically recommended + // during tunnel creation. + // + // This option only applies to CreateTunnel when the tunnel does not already specify a + // 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. + RequiredGeo string } func (options *TunnelRequestOptions) queryString() string { diff --git a/go/tunnels/tunnels.go b/go/tunnels/tunnels.go index c88d8c27..8c9b9aae 100644 --- a/go/tunnels/tunnels.go +++ b/go/tunnels/tunnels.go @@ -10,7 +10,7 @@ import ( "github.com/rodaine/table" ) -const PackageVersion = "0.1.26" +const PackageVersion = "0.1.27" func (tunnel *Tunnel) requestObject() (*Tunnel, error) { convertedTunnel := &Tunnel{ diff --git a/java/src/main/java/com/microsoft/tunnels/contracts/ClusterAvailability.java b/java/src/main/java/com/microsoft/tunnels/contracts/ClusterAvailability.java new file mode 100644 index 00000000..c0a81b74 --- /dev/null +++ b/java/src/main/java/com/microsoft/tunnels/contracts/ClusterAvailability.java @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// Generated from ../../../../../../../../cs/src/Contracts/ClusterAvailability.cs + +package com.microsoft.tunnels.contracts; + +import com.google.gson.annotations.SerializedName; + +/** + * Availability status of a tunneling service cluster. + */ +public enum ClusterAvailability { + /** + * Cluster has sufficient capacity and is fully available. + */ + @SerializedName("Available") + Available, + + /** + * Cluster is approaching capacity limits and may experience delays. + */ + @SerializedName("Degraded") + Degraded, + + /** + * Cluster is at or beyond capacity and should not be used for new tunnels. + */ + @SerializedName("Unavailable") + Unavailable, +} diff --git a/java/src/main/java/com/microsoft/tunnels/contracts/ClusterRecommendation.java b/java/src/main/java/com/microsoft/tunnels/contracts/ClusterRecommendation.java new file mode 100644 index 00000000..e557bc1c --- /dev/null +++ b/java/src/main/java/com/microsoft/tunnels/contracts/ClusterRecommendation.java @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// Generated from ../../../../../../../../cs/src/Contracts/ClusterRecommendation.cs + +package com.microsoft.tunnels.contracts; + +import com.google.gson.annotations.Expose; + +/** + * A single cluster recommendation with availability and capacity details. + */ +public class ClusterRecommendation { + /** + * Gets or sets the cluster ID, e.g. "usw2". + */ + @Expose + public String clusterId; + + /** + * Gets or sets the Azure location name, e.g. "WestUs2". + */ + @Expose + public String azureLocation; + + /** + * Gets or sets the Azure geography name for data residency, e.g. "United States". + */ + @Expose + public String azureGeo; + + /** + * Gets or sets the cluster URI for API requests. + */ + @Expose + public String clusterUri; + + /** + * Gets or sets the availability status of the cluster. + */ + @Expose + public ClusterAvailability availability; + + /** + * Gets or sets the utilization percentage of the cluster. + */ + @Expose + public double utilizationPercent; + + /** + * Gets or sets a human-readable reason for this recommendation's ranking. + */ + @Expose + public String reason; +} diff --git a/java/src/main/java/com/microsoft/tunnels/contracts/ClusterRecommendationResponse.java b/java/src/main/java/com/microsoft/tunnels/contracts/ClusterRecommendationResponse.java new file mode 100644 index 00000000..fa296513 --- /dev/null +++ b/java/src/main/java/com/microsoft/tunnels/contracts/ClusterRecommendationResponse.java @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// Generated from ../../../../../../../../cs/src/Contracts/ClusterRecommendationResponse.cs + +package com.microsoft.tunnels.contracts; + +import com.google.gson.annotations.Expose; + +/** + * Response from the cluster recommendation API containing ranked cluster recommendations. + */ +public class ClusterRecommendationResponse { + /** + * Gets or sets the preferred cluster ID that was requested, if any. + */ + @Expose + public String preferredClusterId; + + /** + * Gets or sets the recommended cluster ID — the best available cluster. Null if no + * clusters are available. + */ + @Expose + public String recommendedClusterId; + + /** + * Gets or sets a value indicating whether the recommendation differs from the + * preferred cluster. + */ + @Expose + public boolean isFallback; + + /** + * Gets or sets the ordered list of cluster recommendations, ranked by preference. + */ + @Expose + public ClusterRecommendation[] recommendations; +} diff --git a/rs/src/contracts/cluster_availability.rs b/rs/src/contracts/cluster_availability.rs new file mode 100644 index 00000000..329a83f6 --- /dev/null +++ b/rs/src/contracts/cluster_availability.rs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// Generated from ../../../cs/src/Contracts/ClusterAvailability.cs + +use serde::{Deserialize, Serialize}; +use std::fmt; + +// Availability status of a tunneling service cluster. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum ClusterAvailability { + // Cluster has sufficient capacity and is fully available. + Available, + + // Cluster is approaching capacity limits and may experience delays. + Degraded, + + // Cluster is at or beyond capacity and should not be used for new tunnels. + Unavailable, +} + +impl fmt::Display for ClusterAvailability { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + ClusterAvailability::Available => write!(f, "Available"), + ClusterAvailability::Degraded => write!(f, "Degraded"), + ClusterAvailability::Unavailable => write!(f, "Unavailable"), + } + } +} diff --git a/rs/src/contracts/cluster_recommendation.rs b/rs/src/contracts/cluster_recommendation.rs new file mode 100644 index 00000000..b05ac4d2 --- /dev/null +++ b/rs/src/contracts/cluster_recommendation.rs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// Generated from ../../../cs/src/Contracts/ClusterRecommendation.cs + +use crate::contracts::ClusterAvailability; +use serde::{Deserialize, Serialize}; + +// A single cluster recommendation with availability and capacity details. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] +pub struct ClusterRecommendation { + // Gets or sets the cluster ID, e.g. "usw2". + pub cluster_id: String, + + // Gets or sets the Azure location name, e.g. "WestUs2". + pub azure_location: String, + + // Gets or sets the Azure geography name for data residency, e.g. "United States". + pub azure_geo: String, + + // Gets or sets the cluster URI for API requests. + pub cluster_uri: String, + + // Gets or sets the availability status of the cluster. + pub availability: ClusterAvailability, + + // Gets or sets the utilization percentage of the cluster. + pub utilization_percent: f64, + + // Gets or sets a human-readable reason for this recommendation's ranking. + pub reason: String, +} diff --git a/rs/src/contracts/cluster_recommendation_response.rs b/rs/src/contracts/cluster_recommendation_response.rs new file mode 100644 index 00000000..55a3ad7b --- /dev/null +++ b/rs/src/contracts/cluster_recommendation_response.rs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// Generated from ../../../cs/src/Contracts/ClusterRecommendationResponse.cs + +use crate::contracts::ClusterRecommendation; +use serde::{Deserialize, Serialize}; + +// Response from the cluster recommendation API containing ranked cluster recommendations. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] +pub struct ClusterRecommendationResponse { + // Gets or sets the preferred cluster ID that was requested, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub preferred_cluster_id: Option, + + // Gets or sets the recommended cluster ID — the best available cluster. Null if no + // clusters are available. + #[serde(skip_serializing_if = "Option::is_none")] + pub recommended_cluster_id: Option, + + // Gets or sets a value indicating whether the recommendation differs from the + // preferred cluster. + pub is_fallback: bool, + + // Gets or sets the ordered list of cluster recommendations, ranked by preference. + pub recommendations: Vec, +} diff --git a/rs/src/contracts/mod.rs b/rs/src/contracts/mod.rs index ac7b445c..1449ab44 100644 --- a/rs/src/contracts/mod.rs +++ b/rs/src/contracts/mod.rs @@ -2,7 +2,10 @@ // Licensed under the MIT license. // Generated from RustContractWriter.cs +mod cluster_availability; mod cluster_details; +mod cluster_recommendation; +mod cluster_recommendation_response; mod error_codes; mod error_detail; mod inner_error_detail; @@ -38,7 +41,10 @@ mod tunnel_report_progress_event_args; mod tunnel_service_properties; mod tunnel_status; +pub use cluster_availability::*; pub use cluster_details::*; +pub use cluster_recommendation::*; +pub use cluster_recommendation_response::*; pub use error_codes::*; pub use error_detail::*; pub use inner_error_detail::*; diff --git a/ts/src/contracts/clusterAvailability.ts b/ts/src/contracts/clusterAvailability.ts new file mode 100644 index 00000000..5a0c46dc --- /dev/null +++ b/ts/src/contracts/clusterAvailability.ts @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// Generated from ../../../cs/src/Contracts/ClusterAvailability.cs +/* eslint-disable */ + +/** + * Availability status of a tunneling service cluster. + */ +export enum ClusterAvailability { + /** + * Cluster has sufficient capacity and is fully available. + */ + Available = 'Available', + + /** + * Cluster is approaching capacity limits and may experience delays. + */ + Degraded = 'Degraded', + + /** + * Cluster is at or beyond capacity and should not be used for new tunnels. + */ + Unavailable = 'Unavailable', +} diff --git a/ts/src/contracts/clusterRecommendation.ts b/ts/src/contracts/clusterRecommendation.ts new file mode 100644 index 00000000..e3967a98 --- /dev/null +++ b/ts/src/contracts/clusterRecommendation.ts @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// Generated from ../../../cs/src/Contracts/ClusterRecommendation.cs +/* eslint-disable */ + +import { ClusterAvailability } from './clusterAvailability'; + +/** + * A single cluster recommendation with availability and capacity details. + */ +export interface ClusterRecommendation { + /** + * Gets or sets the cluster ID, e.g. "usw2". + */ + clusterId: string; + + /** + * Gets or sets the Azure location name, e.g. "WestUs2". + */ + azureLocation: string; + + /** + * Gets or sets the Azure geography name for data residency, e.g. "United States". + */ + azureGeo: string; + + /** + * Gets or sets the cluster URI for API requests. + */ + clusterUri: string; + + /** + * Gets or sets the availability status of the cluster. + */ + availability: ClusterAvailability; + + /** + * Gets or sets the utilization percentage of the cluster. + */ + utilizationPercent: number; + + /** + * Gets or sets a human-readable reason for this recommendation's ranking. + */ + reason: string; +} diff --git a/ts/src/contracts/clusterRecommendationResponse.ts b/ts/src/contracts/clusterRecommendationResponse.ts new file mode 100644 index 00000000..57337acf --- /dev/null +++ b/ts/src/contracts/clusterRecommendationResponse.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// Generated from ../../../cs/src/Contracts/ClusterRecommendationResponse.cs +/* eslint-disable */ + +import { ClusterRecommendation } from './clusterRecommendation'; + +/** + * Response from the cluster recommendation API containing ranked cluster recommendations. + */ +export interface ClusterRecommendationResponse { + /** + * Gets or sets the preferred cluster ID that was requested, if any. + */ + preferredClusterId?: string; + + /** + * Gets or sets the recommended cluster ID — the best available cluster. Null if no + * clusters are available. + */ + recommendedClusterId?: string; + + /** + * Gets or sets a value indicating whether the recommendation differs from the + * preferred cluster. + */ + isFallback?: boolean; + + /** + * Gets or sets the ordered list of cluster recommendations, ranked by preference. + */ + recommendations: ClusterRecommendation[]; +} diff --git a/ts/src/contracts/index.ts b/ts/src/contracts/index.ts index 9b3b367b..76b406c5 100644 --- a/ts/src/contracts/index.ts +++ b/ts/src/contracts/index.ts @@ -20,6 +20,9 @@ export { TunnelRelayTunnelEndpoint } from './tunnelRelayTunnelEndpoint'; export { TunnelServiceProperties } from './tunnelServiceProperties'; export { TunnelStatus } from './tunnelStatus'; export { ClusterDetails } from './clusterDetails'; +export { ClusterAvailability } from './clusterAvailability'; +export { ClusterRecommendation } from './clusterRecommendation'; +export { ClusterRecommendationResponse } from './clusterRecommendationResponse'; export { TunnelListByRegionResponse } from './tunnelListByRegionResponse'; export { TunnelPortListResponse } from './tunnelPortListResponse'; export { TunnelConstraints } from './tunnelConstraints'; diff --git a/ts/src/management/tunnelManagementClient.ts b/ts/src/management/tunnelManagementClient.ts index 3c5c020b..3ea609e8 100644 --- a/ts/src/management/tunnelManagementClient.ts +++ b/ts/src/management/tunnelManagementClient.ts @@ -3,6 +3,7 @@ import { ClusterDetails, + ClusterRecommendationResponse, NamedRateStatus, Tunnel, TunnelEndpoint, @@ -208,6 +209,21 @@ export interface TunnelManagementClient { */ listClusters(cancellation?: CancellationToken): Promise; + /** + * Gets cluster recommendations for tunnel creation based on capacity and + * availability. + * @param preferredClusterId Optional preferred cluster ID. When omitted, defaults to + * the cluster serving the request. + * @param requiredGeo Optional Azure geography filter. When specified, only clusters in + * this geo are eligible for recommendation. + * @param cancellation Optional cancellation token for the request. + */ + getClusterRecommendations( + preferredClusterId?: string, + requiredGeo?: string, + cancellation?: CancellationToken, + ): Promise; + /** * Checks if the tunnel name is available. * @param tunnelName diff --git a/ts/src/management/tunnelManagementHttpClient.ts b/ts/src/management/tunnelManagementHttpClient.ts index 506b57d8..892ca83a 100644 --- a/ts/src/management/tunnelManagementHttpClient.ts +++ b/ts/src/management/tunnelManagementHttpClient.ts @@ -13,6 +13,7 @@ import { ProblemDetails, TunnelServiceProperties, ClusterDetails, + ClusterRecommendationResponse, NamedRateStatus, TunnelListByRegionResponse, TunnelPortListResponse, @@ -41,6 +42,7 @@ const endpointsApiSubPath = '/endpoints'; const portsApiSubPath = '/ports'; const eventsApiSubPath = '/events'; const clustersApiPath = '/clusters'; +const recommendationsSubPath = '/recommendations'; const tunnelAuthentication = 'Authorization'; const checkAvailablePath = ':checkNameAvailability'; const createNameRetries = 3; @@ -328,6 +330,25 @@ export class TunnelManagementHttpClient implements TunnelManagementClient { const tunnelId = tunnel.tunnelId; const idGenerated = tunnelId === undefined || tunnelId === null || tunnelId === ''; options = options || {}; + + // If the caller didn't specify a cluster, auto-select one via the + // recommendations API. Failures fall back to global routing. + if (!tunnel.clusterId) { + try { + const recommendations = await this.getClusterRecommendations( + undefined, + options.requiredGeo, + cancellation, + ); + if (recommendations?.recommendedClusterId) { + tunnel.clusterId = recommendations.recommendedClusterId; + } + } catch { + // Fall through to global (Traffic Manager) routing if the + // recommendations request fails for any reason. + } + } + options.additionalHeaders = options.additionalHeaders || {}; options.additionalHeaders['If-Not-Match'] = "*"; @@ -697,6 +718,32 @@ export class TunnelManagementHttpClient implements TunnelManagementClient { ))!; } + public async getClusterRecommendations( + preferredClusterId?: string, + requiredGeo?: string, + cancellation?: CancellationToken, + ): Promise { + const queryParts: string[] = []; + if (preferredClusterId) { + queryParts.push(`preferredClusterId=${encodeURIComponent(preferredClusterId)}`); + } + if (requiredGeo) { + queryParts.push(`requiredGeo=${encodeURIComponent(requiredGeo)}`); + } + const query = queryParts.length > 0 ? queryParts.join('&') : undefined; + + return (await this.sendRequest( + 'GET', + undefined, + clustersApiPath + recommendationsSubPath, + query, + undefined, + undefined, + false, + cancellation, + ))!; + } + /** * Sends an HTTP request to the tunnel management API, targeting a specific tunnel. * This protected method enables subclasses to support additional tunnel management APIs. diff --git a/ts/src/management/tunnelRequestOptions.ts b/ts/src/management/tunnelRequestOptions.ts index 17151fed..44fe20dd 100644 --- a/ts/src/management/tunnelRequestOptions.ts +++ b/ts/src/management/tunnelRequestOptions.ts @@ -94,4 +94,16 @@ export interface TunnelRequestOptions { * Limits the number of tunnels returned when searching or listing tunnels. */ limit?: number; + + /** + * Gets or sets an optional Azure geography filter used when a cluster is + * automatically recommended during tunnel creation. + * + * This option only applies to `TunnelManagementClient.createTunnel` when the tunnel does + * not already specify a `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. + */ + requiredGeo?: string; } diff --git a/ts/test/tunnels-test/mocks/mockTunnelManagementClient.ts b/ts/test/tunnels-test/mocks/mockTunnelManagementClient.ts index 7c2eba11..a690472d 100644 --- a/ts/test/tunnels-test/mocks/mockTunnelManagementClient.ts +++ b/ts/test/tunnels-test/mocks/mockTunnelManagementClient.ts @@ -10,6 +10,8 @@ import { TunnelEndpoint, TunnelEvent, ClusterDetails, + ClusterAvailability, + ClusterRecommendationResponse, NamedRateStatus, } from '@microsoft/dev-tunnels-contracts'; @@ -272,6 +274,27 @@ export class MockTunnelManagementClient implements TunnelManagementClient { throw new Error('Method not implemented.'); } + getClusterRecommendations( + preferredClusterId?: string, + requiredGeo?: string, + ): Promise { + return Promise.resolve({ + recommendedClusterId: 'localhost', + isFallback: false, + recommendations: [ + { + clusterId: 'localhost', + azureLocation: '', + azureGeo: '', + clusterUri: '', + availability: ClusterAvailability.Available, + utilizationPercent: 0, + reason: '', + }, + ], + }); + } + checkNameAvailablility(tunnelName: string): Promise { throw new Error('Method not implemented.'); } diff --git a/ts/test/tunnels-test/tunnelManagementTests.ts b/ts/test/tunnels-test/tunnelManagementTests.ts index ec877066..8cc4e23d 100644 --- a/ts/test/tunnels-test/tunnelManagementTests.ts +++ b/ts/test/tunnels-test/tunnelManagementTests.ts @@ -6,7 +6,7 @@ import axios, { Axios, AxiosHeaders, AxiosError, AxiosPromise, AxiosRequestConfi import * as https from 'https'; import { suite, test, slow, timeout } from '@testdeck/mocha'; import { ManagementApiVersions, TunnelManagementHttpClient } from '@microsoft/dev-tunnels-management'; -import { Tunnel, TunnelProgress, TunnelReportProgressEventArgs } from '@microsoft/dev-tunnels-contracts'; +import { Tunnel, TunnelProgress, TunnelReportProgressEventArgs, ClusterRecommendationResponse, ClusterAvailability } from '@microsoft/dev-tunnels-contracts'; import { CancellationToken, CancellationTokenSource } from 'vscode-jsonrpc'; @suite @@ -315,6 +315,255 @@ export class TunnelManagementTests { } } + @test + public async getClusterRecommendationsReturnsResponse() { + const response = { + preferredClusterId: 'usw2', + recommendedClusterId: 'usw4', + isFallback: true, + recommendations: [ + { + clusterId: 'usw4', + azureLocation: 'WestUs2', + azureGeo: 'United States', + clusterUri: 'https://usw4.ci.tunnels.dev.api.visualstudio.com', + availability: ClusterAvailability.Available, + utilizationPercent: 12.5, + reason: 'Preferred cluster available', + }, + ], + }; + this.nextResponse = response; + + const result = await this.managementClient.getClusterRecommendations(); + + assert(this.lastRequest && this.lastRequest.uri); + assert(this.lastRequest.uri.includes('/clusters/recommendations')); + assert(result); + assert.strictEqual(result.recommendedClusterId, 'usw4'); + assert.strictEqual(result.recommendations.length, 1); + assert.strictEqual(result.recommendations[0].availability, ClusterAvailability.Available); + assert.strictEqual(result.recommendations[0].utilizationPercent, 12.5); + } + + @test + public async getClusterRecommendationsPassesQueryParameters() { + this.nextResponse = { recommendations: [] }; + + await this.managementClient.getClusterRecommendations('usw2', 'us'); + + assert(this.lastRequest && this.lastRequest.uri); + assert(this.lastRequest.uri.includes('preferredClusterId=usw2')); + assert(this.lastRequest.uri.includes('requiredGeo=us')); + } + + @test + public async createTunnelAutoRecommendsWhenClusterIdNotSet() { + const requestTunnel = { + tunnelId: 'tunnelid', + // clusterId intentionally not set, so the client auto-recommends. + }; + + let recommendationCalls = 0; + let createCalls = 0; + let createUri = ''; + + const originalAxiosRequest = (this.managementClient).axiosRequest; + (this.managementClient).axiosRequest = async ( + config: AxiosRequestConfig, + cancellation: CancellationToken, + ): Promise => { + const uri = config.url || ''; + if (uri.includes('/recommendations')) { + recommendationCalls++; + return { + data: { recommendedClusterId: 'usw4', recommendations: [] }, + status: 200, + statusText: 'OK', + headers: {}, + config, + } as AxiosResponse; + } + + createCalls++; + createUri = uri; + const sentTunnel = config.data as Tunnel; + return { + data: { tunnelId: sentTunnel?.tunnelId, clusterId: 'usw4' }, + status: 200, + statusText: 'OK', + headers: {}, + config, + } as AxiosResponse; + }; + + try { + const result = await this.managementClient.createTunnel(requestTunnel); + + assert.strictEqual(recommendationCalls, 1); + assert.strictEqual(createCalls, 1); + assert.strictEqual(requestTunnel.clusterId, 'usw4'); + assert(createUri.includes('usw4.tunnels.test')); + assert(result); + } finally { + (this.managementClient).axiosRequest = originalAxiosRequest; + } + } + + @test + public async createTunnelForwardsRequiredGeoFromOptionsToRecommendation() { + const requestTunnel = { + tunnelId: 'tunnelid', + // clusterId intentionally not set, so the client auto-recommends. + }; + + let recommendationUri = ''; + let createUri = ''; + + const originalAxiosRequest = (this.managementClient).axiosRequest; + (this.managementClient).axiosRequest = async ( + config: AxiosRequestConfig, + cancellation: CancellationToken, + ): Promise => { + const uri = config.url || ''; + if (uri.includes('/recommendations')) { + recommendationUri = uri; + return { + data: { recommendedClusterId: 'usw4', recommendations: [] }, + status: 200, + statusText: 'OK', + headers: {}, + config, + } as AxiosResponse; + } + + createUri = uri; + const sentTunnel = config.data as Tunnel; + return { + data: { tunnelId: sentTunnel?.tunnelId, clusterId: 'usw4' }, + status: 200, + statusText: 'OK', + headers: {}, + config, + } as AxiosResponse; + }; + + try { + await this.managementClient.createTunnel(requestTunnel, { requiredGeo: 'us' }); + + // requiredGeo flows to the recommendations request... + assert(recommendationUri.includes('requiredGeo=us')); + + // ...but is NOT included on the create-tunnel request itself. + assert(!createUri.includes('requiredGeo')); + } finally { + (this.managementClient).axiosRequest = originalAxiosRequest; + } + } + + @test + public async createTunnelSkipsRecommendWhenClusterIdSet() { + const requestTunnel = { + tunnelId: 'tunnelid', + clusterId: 'usw2', + }; + + let recommendationCalls = 0; + let createCalls = 0; + let createUri = ''; + + const originalAxiosRequest = (this.managementClient).axiosRequest; + (this.managementClient).axiosRequest = async ( + config: AxiosRequestConfig, + cancellation: CancellationToken, + ): Promise => { + const uri = config.url || ''; + if (uri.includes('/recommendations')) { + recommendationCalls++; + } else { + createCalls++; + createUri = uri; + } + const sentTunnel = config.data as Tunnel; + return { + data: { tunnelId: sentTunnel?.tunnelId, clusterId: 'usw2' }, + status: 200, + statusText: 'OK', + headers: {}, + config, + } as AxiosResponse; + }; + + try { + await this.managementClient.createTunnel(requestTunnel); + + assert.strictEqual(recommendationCalls, 0); + assert.strictEqual(createCalls, 1); + assert(createUri.includes('usw2.tunnels.test')); + } finally { + (this.managementClient).axiosRequest = originalAxiosRequest; + } + } + + @test + public async createTunnelFallsBackOnRecommendFailure() { + const requestTunnel = { + tunnelId: 'tunnelid', + // clusterId not set; recommendations call will fail. + }; + + let createCalls = 0; + let createUri = ''; + + const recommendError = new AxiosError(); + recommendError.config = { + url: TunnelManagementTests.testServiceUri, + headers: new AxiosHeaders(), + }; + recommendError.response = { + status: 500, + statusText: 'Internal Server Error', + headers: new AxiosHeaders(), + data: undefined, + config: recommendError.config, + }; + + const originalAxiosRequest = (this.managementClient).axiosRequest; + (this.managementClient).axiosRequest = async ( + config: AxiosRequestConfig, + cancellation: CancellationToken, + ): Promise => { + const uri = config.url || ''; + if (uri.includes('/recommendations')) { + throw recommendError; + } + + createCalls++; + createUri = uri; + const sentTunnel = config.data as Tunnel; + return { + data: { tunnelId: sentTunnel?.tunnelId }, + status: 200, + statusText: 'OK', + headers: {}, + config, + } as AxiosResponse; + }; + + try { + const result = await this.managementClient.createTunnel(requestTunnel); + + assert.strictEqual(createCalls, 1); + assert.strictEqual(requestTunnel.clusterId, undefined); + + // No cluster prefix was added: routing falls back to the global hostname. + assert(createUri.includes('global.tunnels.test')); + assert(result); + } finally { + (this.managementClient).axiosRequest = originalAxiosRequest; + } + } + @test public async handleFirewallResponse() { const requestTunnel = {