Skip to content

Commit 2dea0ba

Browse files
committed
feat: implement PrivateLink client routes support
Add support for PrivateLink/NLB client routes that allows the driver to discover and connect through private endpoints. This includes: - ClientRoutesConfig and ClientRouteProxy for configuration - ClientRoutesTopologyMonitor for route discovery and refresh - ClientRoutesEndPoint for DNS-based endpoint resolution - Protocol-level CLIENT_ROUTES_CHANGE event handling - Coalescing queue for efficient route refresh deduplication - Integration tests with NLB simulator proxy
1 parent 349b6e0 commit 2dea0ba

39 files changed

Lines changed: 6087 additions & 63 deletions
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package com.datastax.oss.driver.api.core.config;
19+
20+
import edu.umd.cs.findbugs.annotations.NonNull;
21+
import edu.umd.cs.findbugs.annotations.Nullable;
22+
import java.util.Objects;
23+
import java.util.regex.Pattern;
24+
import net.jcip.annotations.Immutable;
25+
26+
/**
27+
* Represents a client routes endpoint for cloud private-endpoint deployments (e.g. AWS PrivateLink,
28+
* Azure Private Link, GCP Private Service Connect).
29+
*
30+
* <p>Each endpoint corresponds to a connection ID in the {@code system.client_routes} table, with
31+
* an optional connection address (DNS name or IP) that overrides the {@code address} column from
32+
* the table for the matching connection ID.
33+
*/
34+
@Immutable
35+
public final class ClientRouteProxy {
36+
37+
/**
38+
* Pattern for valid hostnames and IP addresses: alphanumeric characters, dots, hyphens,
39+
* underscores, colons (for IPv6), and brackets (for bracketed IPv6 notation). Strings that don't
40+
* match will be rejected early rather than failing with a confusing DNS resolution error later.
41+
*/
42+
private static final Pattern VALID_HOSTNAME_OR_IP = Pattern.compile("^[a-zA-Z0-9._:\\[\\]-]+$");
43+
44+
private final String connectionId;
45+
private final String connectionAddr;
46+
47+
/**
48+
* Creates a new endpoint with the given connection ID and no connection address.
49+
*
50+
* @param connectionId the connection ID (must not be null).
51+
*/
52+
public ClientRouteProxy(@NonNull String connectionId) {
53+
this(connectionId, null);
54+
}
55+
56+
/**
57+
* Creates a new endpoint with the given connection ID and connection address.
58+
*
59+
* @param connectionId the connection ID (must not be null).
60+
* @param connectionAddr the DNS name or IP address used to override the {@code address} column
61+
* from the {@code system.client_routes} table for the matching {@code connection_id} (may be
62+
* null). Must be a plain hostname or IP address without a port (e.g. {@code
63+
* "my-cluster.example.com"} or {@code "10.0.1.5"}).
64+
*/
65+
public ClientRouteProxy(@NonNull String connectionId, @Nullable String connectionAddr) {
66+
this.connectionId = Objects.requireNonNull(connectionId, "connectionId must not be null");
67+
if (connectionId.trim().isEmpty()) {
68+
throw new IllegalArgumentException("connectionId must not be empty");
69+
}
70+
if (connectionAddr != null) {
71+
if (connectionAddr.trim().isEmpty()) {
72+
throw new IllegalArgumentException(
73+
"connectionAddr must not be empty or blank when non-null");
74+
}
75+
if (containsPort(connectionAddr)) {
76+
throw new IllegalArgumentException(
77+
"connectionAddr must be a plain hostname or IP address without a port, got: "
78+
+ connectionAddr);
79+
}
80+
if (!VALID_HOSTNAME_OR_IP.matcher(connectionAddr).matches()) {
81+
throw new IllegalArgumentException(
82+
"connectionAddr contains invalid characters "
83+
+ "(expected hostname or IP address with only alphanumeric characters, "
84+
+ "dots, hyphens, underscores, colons, or brackets): "
85+
+ connectionAddr);
86+
}
87+
}
88+
this.connectionAddr = connectionAddr;
89+
}
90+
91+
/**
92+
* Returns {@code true} if the address string appears to contain a port suffix. Handles plain
93+
* {@code host:port} and IPv6 bracketed {@code [host]:port} notation.
94+
*/
95+
private static boolean containsPort(@NonNull String addr) {
96+
if (addr.startsWith("[")) {
97+
// IPv6 bracketed notation: [host]:port
98+
int closeBracket = addr.indexOf(']');
99+
return closeBracket > 0
100+
&& closeBracket + 1 < addr.length()
101+
&& addr.charAt(closeBracket + 1) == ':';
102+
}
103+
int colonIdx = addr.lastIndexOf(':');
104+
if (colonIdx < 0) {
105+
return false;
106+
}
107+
// Leading colon (e.g. ":9042") — treat as port-only, no valid hostname
108+
if (colonIdx == 0) {
109+
return true;
110+
}
111+
// Multiple colons means bare IPv6 address (e.g. "::1"), not host:port
112+
return addr.indexOf(':') == colonIdx;
113+
}
114+
115+
/** Returns the connection ID for this endpoint. */
116+
@NonNull
117+
public String getConnectionId() {
118+
return connectionId;
119+
}
120+
121+
/**
122+
* Returns the connection address for this endpoint, or null if not specified.
123+
*
124+
* <p>This is a plain hostname or IP address (e.g. {@code "my-cluster.example.com"} or {@code
125+
* "10.0.1.5"}). When provided, the {@code address} column from the {@code system.client_routes}
126+
* table is overridden with this value for the matching {@code connection_id}.
127+
*/
128+
@Nullable
129+
public String getConnectionAddr() {
130+
return connectionAddr;
131+
}
132+
133+
@Override
134+
public boolean equals(Object o) {
135+
if (this == o) {
136+
return true;
137+
}
138+
if (!(o instanceof ClientRouteProxy)) {
139+
return false;
140+
}
141+
ClientRouteProxy that = (ClientRouteProxy) o;
142+
return connectionId.equals(that.connectionId)
143+
&& Objects.equals(connectionAddr, that.connectionAddr);
144+
}
145+
146+
@Override
147+
public int hashCode() {
148+
return Objects.hash(connectionId, connectionAddr);
149+
}
150+
151+
@Override
152+
public String toString() {
153+
return "ClientRouteProxy{"
154+
+ "connectionId='"
155+
+ connectionId
156+
+ "'"
157+
+ (connectionAddr != null ? ", connectionAddr='" + connectionAddr + "'" : "")
158+
+ "}";
159+
}
160+
}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package com.datastax.oss.driver.api.core.config;
19+
20+
import com.datastax.oss.driver.api.core.session.SessionBuilder;
21+
import com.datastax.oss.driver.shaded.guava.common.annotations.VisibleForTesting;
22+
import edu.umd.cs.findbugs.annotations.NonNull;
23+
import java.util.ArrayList;
24+
import java.util.Collections;
25+
import java.util.HashSet;
26+
import java.util.List;
27+
import java.util.Objects;
28+
import java.util.Set;
29+
import java.util.regex.Pattern;
30+
import net.jcip.annotations.Immutable;
31+
32+
/**
33+
* Configuration for client routes, used in cloud private-endpoint deployments.
34+
*
35+
* <p>Client routes enable the driver to discover and connect to nodes through a load balancer (such
36+
* as AWS PrivateLink, Azure Private Link, or GCP Private Service Connect) by reading endpoint
37+
* mappings from the {@code system.client_routes} table. Each endpoint is identified by a connection
38+
* ID and maps to specific node addresses.
39+
*
40+
* <p>This configuration is mutually exclusive with a user-provided {@link
41+
* com.datastax.oss.driver.api.core.addresstranslation.AddressTranslator}. If client routes are
42+
* configured, the driver will use its internal client routes handler for address translation.
43+
*
44+
* <p>Example usage:
45+
*
46+
* <pre>{@code
47+
* ClientRoutesConfig config = ClientRoutesConfig.builder()
48+
* .addEndpoint(new ClientRouteProxy(
49+
* "12345678-1234-1234-1234-123456789012",
50+
* "my-cluster-endpoint.example.com"))
51+
* .build();
52+
*
53+
* CqlSession session = CqlSession.builder()
54+
* .withClientRoutesConfig(config)
55+
* .build();
56+
* }</pre>
57+
*
58+
* @see SessionBuilder#withClientRoutesConfig(ClientRoutesConfig)
59+
* @see ClientRouteProxy
60+
*/
61+
@Immutable
62+
public final class ClientRoutesConfig {
63+
64+
private static final String DEFAULT_TABLE_NAME = "system.client_routes";
65+
66+
/**
67+
* Pattern for valid unquoted CQL table names: must start with a letter or underscore, followed by
68+
* letters, digits, or underscores. Optionally qualified with a keyspace prefix using the same
69+
* rules. Quoted identifiers (e.g. {@code "My-Table"}) are not supported.
70+
*/
71+
private static final Pattern VALID_TABLE_NAME =
72+
Pattern.compile("[a-zA-Z_][a-zA-Z0-9_]*(\\.[a-zA-Z_][a-zA-Z0-9_]*)?");
73+
74+
private final List<ClientRouteProxy> endpoints;
75+
private final String tableName;
76+
77+
private ClientRoutesConfig(List<ClientRouteProxy> endpoints, String tableName) {
78+
if (endpoints == null || endpoints.isEmpty()) {
79+
throw new IllegalArgumentException("At least one endpoint must be specified");
80+
}
81+
Objects.requireNonNull(tableName, "tableName must not be null");
82+
if (!VALID_TABLE_NAME.matcher(tableName).matches()) {
83+
throw new IllegalArgumentException(
84+
"Invalid table name (must match [a-zA-Z_][a-zA-Z0-9_]*(\\.[a-zA-Z_][a-zA-Z0-9_]*)?): "
85+
+ tableName);
86+
}
87+
this.endpoints = Collections.unmodifiableList(new ArrayList<>(endpoints));
88+
this.tableName = tableName;
89+
}
90+
91+
/**
92+
* Returns the list of configured endpoints.
93+
*
94+
* @return an immutable list of endpoints.
95+
*/
96+
@NonNull
97+
public List<ClientRouteProxy> getEndpoints() {
98+
return endpoints;
99+
}
100+
101+
/**
102+
* Returns the name of the system table to query for client routes.
103+
*
104+
* @return the table name (defaults to {@code system.client_routes}).
105+
*/
106+
@NonNull
107+
public String getTableName() {
108+
return tableName;
109+
}
110+
111+
/**
112+
* Creates a new builder for constructing a {@link ClientRoutesConfig}.
113+
*
114+
* @return a new builder instance.
115+
*/
116+
@NonNull
117+
public static Builder builder() {
118+
return new Builder();
119+
}
120+
121+
@Override
122+
public boolean equals(Object o) {
123+
if (this == o) {
124+
return true;
125+
}
126+
if (!(o instanceof ClientRoutesConfig)) {
127+
return false;
128+
}
129+
ClientRoutesConfig that = (ClientRoutesConfig) o;
130+
return endpoints.equals(that.endpoints) && tableName.equals(that.tableName);
131+
}
132+
133+
@Override
134+
public int hashCode() {
135+
return Objects.hash(endpoints, tableName);
136+
}
137+
138+
@Override
139+
public String toString() {
140+
return "ClientRoutesConfig{"
141+
+ "endpoints="
142+
+ endpoints
143+
+ ", tableName='"
144+
+ tableName
145+
+ '\''
146+
+ '}';
147+
}
148+
149+
/** Builder for {@link ClientRoutesConfig}. */
150+
public static final class Builder {
151+
private final List<ClientRouteProxy> endpoints = new ArrayList<>();
152+
private final Set<String> seenConnectionIds = new HashSet<>();
153+
private String tableName = DEFAULT_TABLE_NAME;
154+
155+
/**
156+
* Adds an endpoint to the configuration.
157+
*
158+
* @param endpoint the endpoint to add (must not be null).
159+
* @return this builder.
160+
* @throws IllegalArgumentException if an endpoint with the same connection ID has already been
161+
* added.
162+
*/
163+
@NonNull
164+
public Builder addEndpoint(@NonNull ClientRouteProxy endpoint) {
165+
Objects.requireNonNull(endpoint, "endpoint must not be null");
166+
if (!seenConnectionIds.add(endpoint.getConnectionId())) {
167+
throw new IllegalArgumentException(
168+
"Duplicate connection ID: " + endpoint.getConnectionId());
169+
}
170+
this.endpoints.add(endpoint);
171+
return this;
172+
}
173+
174+
/**
175+
* Sets the endpoints for the configuration, replacing any previously added endpoints.
176+
*
177+
* @param endpoints the endpoints to set (must not be null or empty).
178+
* @return this builder.
179+
*/
180+
@NonNull
181+
public Builder withEndpoints(@NonNull List<ClientRouteProxy> endpoints) {
182+
Objects.requireNonNull(endpoints, "endpoints must not be null");
183+
if (endpoints.isEmpty()) {
184+
throw new IllegalArgumentException("endpoints must not be empty");
185+
}
186+
this.endpoints.clear();
187+
this.seenConnectionIds.clear();
188+
for (ClientRouteProxy endpoint : endpoints) {
189+
addEndpoint(endpoint);
190+
}
191+
return this;
192+
}
193+
194+
/**
195+
* Sets the name of the system table to query for client routes.
196+
*
197+
* <p>This is primarily useful for testing. If not set, the driver will use the default table
198+
* name from the configuration ({@code system.client_routes}).
199+
*
200+
* @param tableName the table name to use (must not be null).
201+
* @return this builder.
202+
* @throws NullPointerException if {@code tableName} is null.
203+
*/
204+
@VisibleForTesting
205+
@NonNull
206+
Builder withTableName(@NonNull String tableName) {
207+
this.tableName = Objects.requireNonNull(tableName, "tableName must not be null");
208+
return this;
209+
}
210+
211+
/**
212+
* Builds the {@link ClientRoutesConfig} with the configured endpoints and table name.
213+
*
214+
* @return the new configuration instance.
215+
* @throws IllegalArgumentException if no endpoints have been added.
216+
*/
217+
@NonNull
218+
public ClientRoutesConfig build() {
219+
return new ClientRoutesConfig(endpoints, tableName);
220+
}
221+
}
222+
}

0 commit comments

Comments
 (0)