Skip to content

Commit 3bdfa43

Browse files
Extend cloud ID parsing to support port extraction and Kibana targeting (#197)
## Summary - Extends `CloudNodePool.ParseCloudId` to extract per-service ports from the cloud ID, aligning with [how Kibana parses cloud IDs](elastic/kibana#159442). The format `host:port$esId:esPort$kbId:kbPort` is now fully supported, with port 443 as the default. - Adds a `CloudService` enum (`Elasticsearch`, `Kibana`) so the transport can target either service from the same cloud ID. - Adds new constructor overloads on `CloudNodePool`, `TransportConfiguration`, `TransportConfigurationDescriptor`, and `ElasticsearchConfiguration` that accept a `CloudService` parameter. - All existing constructors are preserved to avoid binary breaking changes. ## Test plan - [x] 17 new unit tests in `CloudNodePoolTests` covering: basic parsing, ES targeting, Kibana targeting, custom host port, per-service port overrides, default port (443) omission, error cases (missing Kibana UUID, empty cloud ID, etc.), and backward compatibility of old constructors. - [x] All tests pass on both `net10.0` and `net481`. - [x] Full existing test suite continues to pass (the one pre-existing `SerializesException` failure on `net481` is unrelated). Made with [Cursor](https://cursor.com) Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1bfa90f commit 3bdfa43

5 files changed

Lines changed: 295 additions & 16 deletions

File tree

src/Elastic.Transport/Components/NodePool/CloudNodePool.cs

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@
77

88
namespace Elastic.Transport;
99

10+
/// <summary>
11+
/// Specifies which Elastic Cloud service to target when using a Cloud ID.
12+
/// </summary>
13+
public enum CloudService
14+
{
15+
/// <summary> Target the Elasticsearch cluster (default). </summary>
16+
Elasticsearch,
17+
/// <summary> Target the Kibana instance. </summary>
18+
Kibana,
19+
}
20+
1021
/// <summary>
1122
/// An <see cref="NodePool"/> implementation that can be seeded with a cloud id
1223
/// and will signal the right defaults for the client to use for Elastic Cloud to <see cref="ITransportConfiguration"/>.
@@ -16,7 +27,7 @@ namespace Elastic.Transport;
1627
/// </summary>
1728
public sealed class CloudNodePool : SingleNodePool
1829
{
19-
private readonly record struct ParsedCloudId(string ClusterName, Uri Uri);
30+
private readonly record struct ParsedCloudId(string ClusterName, Uri ElasticsearchUri, Uri? KibanaUri);
2031

2132
/// <summary>
2233
/// An <see cref="NodePool"/> implementation that can be seeded with a cloud id
@@ -28,31 +39,52 @@ public sealed class CloudNodePool : SingleNodePool
2839
/// <param name="cloudId">
2940
/// The Cloud Id, this is available on your cluster's dashboard and is a string in the form of <code>cluster_name:base_64_encoded_string</code>
3041
/// <para>Base64 encoded string contains the following tokens in order separated by $:</para>
31-
/// <para>* Host Name (mandatory)</para>
32-
/// <para>* Elasticsearch UUID (mandatory)</para>
33-
/// <para>* Kibana UUID</para>
42+
/// <para>* Host Name (mandatory, optionally with :port, defaults to 443)</para>
43+
/// <para>* Elasticsearch UUID (mandatory, optionally with :port)</para>
44+
/// <para>* Kibana UUID (optionally with :port)</para>
3445
/// <para>* APM UUID</para>
3546
/// <para></para>
3647
/// <para> We then use these tokens to create the URI to your Elastic Cloud cluster!</para>
3748
/// <para></para>
3849
/// <para> Read more here: https://www.elastic.co/guide/en/cloud/current/ec-cloud-id.html</para>
3950
/// </param>
40-
/// <param name="credentials"></param>
41-
public CloudNodePool(string cloudId, AuthorizationHeader credentials) : this(ParseCloudId(cloudId)) =>
51+
/// <param name="credentials">The credentials to use for authentication.</param>
52+
public CloudNodePool(string cloudId, AuthorizationHeader credentials)
53+
: this(ParseCloudId(cloudId), CloudService.Elasticsearch) =>
54+
AuthenticationHeader = credentials;
55+
56+
/// <inheritdoc cref="CloudNodePool(string, AuthorizationHeader)"/>
57+
/// <param name="cloudId"><inheritdoc cref="CloudNodePool(string, AuthorizationHeader)" path="/param[@name='cloudId']"/></param>
58+
/// <param name="credentials"><inheritdoc cref="CloudNodePool(string, AuthorizationHeader)" path="/param[@name='credentials']"/></param>
59+
/// <param name="service">Which cloud service to target. Defaults to <see cref="CloudService.Elasticsearch"/>.</param>
60+
public CloudNodePool(string cloudId, AuthorizationHeader credentials, CloudService service)
61+
: this(ParseCloudId(cloudId), service) =>
4262
AuthenticationHeader = credentials;
4363

4464
/// <summary>
45-
/// An <see cref="NodePool"/> implementation that can be seeded with a cloud enpoint
65+
/// An <see cref="NodePool"/> implementation that can be seeded with a cloud endpoint
4666
/// and will signal the right defaults for the client to use for Elastic Cloud to <see cref="ITransportConfiguration"/>.
4767
/// </summary>
4868
/// <param name="cloudEndpoint">Elastic Cloud endpoint</param>
4969
/// <param name="credentials">The credentials to use with cloud</param>
50-
public CloudNodePool(Uri cloudEndpoint, AuthorizationHeader credentials) : this(CreateCloudId(cloudEndpoint)) =>
70+
public CloudNodePool(Uri cloudEndpoint, AuthorizationHeader credentials) : this(CreateCloudId(cloudEndpoint), CloudService.Elasticsearch) =>
5171
AuthenticationHeader = credentials;
5272

53-
private CloudNodePool(ParsedCloudId parsedCloudId) : base(parsedCloudId.Uri) =>
73+
private CloudNodePool(ParsedCloudId parsedCloudId, CloudService service) : base(ResolveUri(parsedCloudId, service)) =>
5474
ClusterName = parsedCloudId.ClusterName;
5575

76+
private static Uri ResolveUri(ParsedCloudId parsed, CloudService service)
77+
{
78+
if (service == CloudService.Kibana)
79+
{
80+
if (parsed.KibanaUri is null)
81+
throw new ArgumentException("The cloud ID does not contain a Kibana UUID. Cannot target Kibana.");
82+
return parsed.KibanaUri;
83+
}
84+
85+
return parsed.ElasticsearchUri;
86+
}
87+
5688
//TODO implement debugger display for NodePool implementations and display it there and its ToString()
5789
// ReSharper disable once UnusedAutoPropertyAccessor.Local
5890
private string ClusterName { get; }
@@ -66,13 +98,20 @@ private static ParsedCloudId CreateCloudId(Uri uri)
6698
var moniker = $"{uri.Host}${Guid.NewGuid():N}";
6799
var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(moniker));
68100
var cloudId = $"name:{base64}";
69-
return new ParsedCloudId(cloudId, uri);
70-
101+
return new ParsedCloudId(cloudId, uri, null);
71102
}
72103

73104
private static readonly char[] ColonSeparator = [':'];
74105
private static readonly char[] DollarSeparator = ['$'];
75106

107+
private static (string Id, string Port) ExtractPortFromId(string input, string defaultPort = "443")
108+
{
109+
var colonIndex = input.IndexOf(':');
110+
if (colonIndex < 0)
111+
return (input, defaultPort);
112+
return (input[..colonIndex], input[(colonIndex + 1)..]);
113+
}
114+
76115
private static ParsedCloudId ParseCloudId(string cloudId)
77116
{
78117
const string exceptionSuffix = "should be a string in the form of cluster_name:base_64_data";
@@ -93,14 +132,33 @@ private static ParsedCloudId ParseCloudId(string cloudId)
93132
if (parts.Length < 2)
94133
throw new ArgumentException($"Parameter {nameof(cloudId)} decoded base_64_data contains less then 2 tokens, {exceptionSuffix}", nameof(cloudId));
95134

96-
var domainName = parts[0].Trim();
97-
if (string.IsNullOrWhiteSpace(domainName))
135+
var (host, defaultPort) = ExtractPortFromId(parts[0].Trim());
136+
if (string.IsNullOrWhiteSpace(host))
98137
throw new ArgumentException($"Parameter {nameof(cloudId)} decoded base_64_data contains no domain name, {exceptionSuffix}", nameof(cloudId));
99138

100-
var elasticsearchUuid = parts[1].Trim();
101-
if (string.IsNullOrWhiteSpace(elasticsearchUuid))
139+
var (esId, esPort) = ExtractPortFromId(parts[1].Trim(), defaultPort);
140+
if (string.IsNullOrWhiteSpace(esId))
102141
throw new ArgumentException($"Parameter {nameof(cloudId)} decoded base_64_data contains no elasticsearch UUID, {exceptionSuffix}", nameof(cloudId));
103142

104-
return new ParsedCloudId(clusterName, new Uri($"https://{elasticsearchUuid}.{domainName}"));
143+
var esUri = BuildServiceUri(esId, host, esPort);
144+
145+
Uri? kibanaUri = null;
146+
if (parts.Length >= 3)
147+
{
148+
var kibanaRaw = parts[2].Trim();
149+
if (!string.IsNullOrWhiteSpace(kibanaRaw))
150+
{
151+
var (kbId, kbPort) = ExtractPortFromId(kibanaRaw, defaultPort);
152+
if (!string.IsNullOrWhiteSpace(kbId))
153+
kibanaUri = BuildServiceUri(kbId, host, kbPort);
154+
}
155+
}
156+
157+
return new ParsedCloudId(clusterName, esUri, kibanaUri);
105158
}
159+
160+
private static Uri BuildServiceUri(string serviceId, string host, string port) =>
161+
port == "443"
162+
? new Uri($"https://{serviceId}.{host}")
163+
: new Uri($"https://{serviceId}.{host}:{port}");
106164
}

src/Elastic.Transport/Configuration/TransportConfiguration.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,29 @@ public TransportConfiguration(Uri? uri = null, ProductRegistration? productRegis
6464
public TransportConfiguration(string cloudId, BasicAuthentication credentials, ProductRegistration? productRegistration = null)
6565
: this(new CloudNodePool(cloudId, credentials), productRegistration: productRegistration) { }
6666

67+
/// <summary>
68+
/// Sets up the client to communicate to Elastic Cloud using <paramref name="cloudId"/>,
69+
/// targeting the specified <paramref name="service"/>.
70+
/// <para><see cref="CloudNodePool"/> documentation for more information on how to obtain your Cloud Id</para>
71+
/// </summary>
72+
public TransportConfiguration(string cloudId, BasicAuthentication credentials, CloudService service, ProductRegistration? productRegistration = null)
73+
: this(new CloudNodePool(cloudId, credentials, service), productRegistration: productRegistration) { }
74+
6775
/// <summary>
6876
/// Sets up the client to communicate to Elastic Cloud using <paramref name="cloudId"/>,
6977
/// <para><see cref="CloudNodePool"/> documentation for more information on how to obtain your Cloud Id</para>
7078
/// </summary>
7179
public TransportConfiguration(string cloudId, ApiKey credentials, ProductRegistration? productRegistration = null)
7280
: this(new CloudNodePool(cloudId, credentials), productRegistration: productRegistration) { }
7381

82+
/// <summary>
83+
/// Sets up the client to communicate to Elastic Cloud using <paramref name="cloudId"/>,
84+
/// targeting the specified <paramref name="service"/>.
85+
/// <para><see cref="CloudNodePool"/> documentation for more information on how to obtain your Cloud Id</para>
86+
/// </summary>
87+
public TransportConfiguration(string cloudId, ApiKey credentials, CloudService service, ProductRegistration? productRegistration = null)
88+
: this(new CloudNodePool(cloudId, credentials, service), productRegistration: productRegistration) { }
89+
7490
/// <summary> Sets up the client to communicate to Elastic Cloud.</summary>
7591
public TransportConfiguration(Uri cloudEndpoint, BasicAuthentication credentials, ProductRegistration? productRegistration = null)
7692
: this(new CloudNodePool(cloudEndpoint, credentials), productRegistration: productRegistration) { }

src/Elastic.Transport/Configuration/TransportConfigurationDescriptor.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,29 @@ public TransportConfigurationDescriptor(Uri? uri = null, ProductRegistration? pr
4848
public TransportConfigurationDescriptor(string cloudId, BasicAuthentication credentials, ProductRegistration? productRegistration = null)
4949
: this(new CloudNodePool(cloudId, credentials), productRegistration: productRegistration) { }
5050

51+
/// <summary>
52+
/// Sets up the client to communicate to Elastic Cloud using <paramref name="cloudId"/>,
53+
/// targeting the specified <paramref name="service"/>.
54+
/// <para><see cref="CloudNodePool"/> documentation for more information on how to obtain your Cloud Id</para>
55+
/// </summary>
56+
public TransportConfigurationDescriptor(string cloudId, BasicAuthentication credentials, CloudService service, ProductRegistration? productRegistration = null)
57+
: this(new CloudNodePool(cloudId, credentials, service), productRegistration: productRegistration) { }
58+
5159
/// <summary>
5260
/// Sets up the client to communicate to Elastic Cloud using <paramref name="cloudId"/>,
5361
/// <para><see cref="CloudNodePool"/> documentation for more information on how to obtain your Cloud Id</para>
5462
/// </summary>
5563
public TransportConfigurationDescriptor(string cloudId, ApiKey credentials, ProductRegistration? productRegistration = null)
5664
: this(new CloudNodePool(cloudId, credentials), productRegistration: productRegistration) { }
5765

66+
/// <summary>
67+
/// Sets up the client to communicate to Elastic Cloud using <paramref name="cloudId"/>,
68+
/// targeting the specified <paramref name="service"/>.
69+
/// <para><see cref="CloudNodePool"/> documentation for more information on how to obtain your Cloud Id</para>
70+
/// </summary>
71+
public TransportConfigurationDescriptor(string cloudId, ApiKey credentials, CloudService service, ProductRegistration? productRegistration = null)
72+
: this(new CloudNodePool(cloudId, credentials, service), productRegistration: productRegistration) { }
73+
5874
/// <summary>
5975
/// Creates a new instance of <see cref="TransportConfigurationDescriptor"/>
6076
/// <para>

src/Elastic.Transport/Products/Elasticsearch/ElasticsearchConfiguration.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,29 @@ public ElasticsearchConfiguration(Uri? uri = null)
2525
public ElasticsearchConfiguration(string cloudId, BasicAuthentication credentials)
2626
: base(new CloudNodePool(cloudId, credentials), productRegistration: ElasticsearchProductRegistration.Default) { }
2727

28+
/// <summary>
29+
/// Sets up the client to communicate to Elastic Cloud using <paramref name="cloudId"/>,
30+
/// targeting the specified <paramref name="service"/>.
31+
/// <para><see cref="CloudNodePool"/> documentation for more information on how to get your Cloud ID</para>
32+
/// </summary>
33+
public ElasticsearchConfiguration(string cloudId, BasicAuthentication credentials, CloudService service)
34+
: base(new CloudNodePool(cloudId, credentials, service), productRegistration: ElasticsearchProductRegistration.Default) { }
35+
2836
/// <summary>
2937
/// Sets up the client to communicate to Elastic Cloud using <paramref name="cloudId"/>,
3038
/// <para><see cref="CloudNodePool"/> documentation for more information on how to get your Cloud ID</para>
3139
/// </summary>
3240
public ElasticsearchConfiguration(string cloudId, ApiKey credentials)
3341
: base(new CloudNodePool(cloudId, credentials), productRegistration: ElasticsearchProductRegistration.Default) { }
3442

43+
/// <summary>
44+
/// Sets up the client to communicate to Elastic Cloud using <paramref name="cloudId"/>,
45+
/// targeting the specified <paramref name="service"/>.
46+
/// <para><see cref="CloudNodePool"/> documentation for more information on how to get your Cloud ID</para>
47+
/// </summary>
48+
public ElasticsearchConfiguration(string cloudId, ApiKey credentials, CloudService service)
49+
: base(new CloudNodePool(cloudId, credentials, service), productRegistration: ElasticsearchProductRegistration.Default) { }
50+
3551
/// <summary> Sets up the client to communicate to Elastic Cloud.</summary>
3652
public ElasticsearchConfiguration(Uri cloudEndpoint, BasicAuthentication credentials)
3753
: base(new CloudNodePool(cloudEndpoint, credentials), productRegistration: ElasticsearchProductRegistration.Default) { }

0 commit comments

Comments
 (0)