Skip to content

Commit 4731c8d

Browse files
committed
minimal impl
1 parent be8769b commit 4731c8d

23 files changed

+2219
-139
lines changed
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
/*
2+
* Copyright 2026 LY Corporation
3+
*
4+
* LY Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
package com.linecorp.armeria.xds.client.endpoint;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.awaitility.Awaitility.await;
21+
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.Map.Entry;
25+
import java.util.concurrent.atomic.AtomicReference;
26+
27+
import org.junit.jupiter.api.Test;
28+
29+
import com.google.protobuf.Struct;
30+
import com.google.protobuf.Value;
31+
32+
import com.linecorp.armeria.xds.ClusterSnapshot;
33+
import com.linecorp.armeria.xds.ListenerRoot;
34+
import com.linecorp.armeria.xds.ListenerSnapshot;
35+
import com.linecorp.armeria.xds.TransportSocketMatchSnapshot;
36+
import com.linecorp.armeria.xds.TransportSocketSnapshot;
37+
import com.linecorp.armeria.xds.XdsBootstrap;
38+
import com.linecorp.armeria.xds.it.XdsResourceReader;
39+
40+
import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap;
41+
import io.envoyproxy.envoy.config.endpoint.v3.ClusterLoadAssignment;
42+
import io.envoyproxy.envoy.config.endpoint.v3.LbEndpoint;
43+
import io.envoyproxy.envoy.config.endpoint.v3.LocalityLbEndpoints;
44+
45+
class TransportSocketMatchUtilTest {
46+
47+
// language=YAML
48+
private static final String bootstrapYaml =
49+
"""
50+
static_resources:
51+
listeners:
52+
- name: my-listener
53+
api_listener:
54+
api_listener:
55+
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager\
56+
.v3.HttpConnectionManager
57+
stat_prefix: http
58+
route_config:
59+
name: local_route
60+
virtual_hosts:
61+
- name: local_service1
62+
domains: [ "*" ]
63+
routes:
64+
- match:
65+
prefix: /
66+
route:
67+
cluster: my-cluster
68+
http_filters:
69+
- name: envoy.filters.http.router
70+
typed_config:
71+
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
72+
clusters:
73+
- name: my-cluster
74+
type: STATIC
75+
load_assignment:
76+
cluster_name: my-cluster
77+
endpoints:
78+
- locality:
79+
region: us-east-1
80+
metadata:
81+
filter_metadata:
82+
"envoy.transport_socket_match":
83+
region: us-east-1
84+
lb_endpoints:
85+
- endpoint:
86+
address:
87+
socket_address:
88+
address: 127.0.0.1
89+
port_value: 8080
90+
metadata:
91+
filter_metadata:
92+
"envoy.transport_socket_match":
93+
env: prod
94+
- endpoint:
95+
address:
96+
socket_address:
97+
address: 127.0.0.1
98+
port_value: 8081
99+
metadata:
100+
filter_metadata:
101+
"envoy.transport_socket_match":
102+
env: staging
103+
- locality:
104+
region: us-west-1
105+
metadata:
106+
filter_metadata:
107+
"envoy.transport_socket_match":
108+
region: us-west-1
109+
lb_endpoints:
110+
- endpoint:
111+
address:
112+
socket_address:
113+
address: 127.0.0.1
114+
port_value: 8082
115+
metadata:
116+
filter_metadata:
117+
"envoy.transport_socket_match":
118+
env: staging
119+
transport_socket:
120+
name: envoy.transport_sockets.tls
121+
typed_config:
122+
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
123+
transport_socket_matches:
124+
- name: endpoint-match
125+
match:
126+
env: prod
127+
transport_socket:
128+
name: envoy.transport_sockets.tls
129+
typed_config:
130+
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
131+
- name: locality-match
132+
match:
133+
region: us-east-1
134+
transport_socket:
135+
name: envoy.transport_sockets.tls
136+
typed_config:
137+
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
138+
""";
139+
140+
@Test
141+
void matchesEmptyCriteria() {
142+
assertThat(TransportSocketMatchUtil.matches(Struct.getDefaultInstance(),
143+
Struct.getDefaultInstance())).isTrue();
144+
}
145+
146+
@Test
147+
void matchesRequiresAllPairs() {
148+
final Struct criteria = struct(Map.of("env", "prod", "zone", "a"));
149+
final Struct metadata = struct(Map.of("env", "prod", "zone", "a", "extra", "x"));
150+
assertThat(TransportSocketMatchUtil.matches(criteria, metadata)).isTrue();
151+
152+
final Struct mismatched = struct(Map.of("env", "staging", "zone", "a"));
153+
assertThat(TransportSocketMatchUtil.matches(criteria, mismatched)).isFalse();
154+
}
155+
156+
@Test
157+
void endpointAndLocalityMetadataExtraction() throws Exception {
158+
withClusterSnapshot(clusterSnapshot -> {
159+
final ClusterLoadAssignment loadAssignment = loadAssignment(clusterSnapshot);
160+
final LocalityLbEndpoints locality = loadAssignment.getEndpoints(0);
161+
final LbEndpoint lbEndpoint = locality.getLbEndpoints(0);
162+
163+
assertThat(TransportSocketMatchUtil.endpointMatchMetadata(lbEndpoint))
164+
.isEqualTo(struct(Map.of("env", "prod")));
165+
assertThat(TransportSocketMatchUtil.localityMatchMetadata(locality))
166+
.isEqualTo(struct(Map.of("region", "us-east-1")));
167+
168+
assertThat(TransportSocketMatchUtil.endpointMatchMetadata(LbEndpoint.getDefaultInstance())
169+
.getFieldsCount()).isZero();
170+
assertThat(TransportSocketMatchUtil.localityMatchMetadata(LocalityLbEndpoints.getDefaultInstance())
171+
.getFieldsCount()).isZero();
172+
});
173+
}
174+
175+
@Test
176+
void selectTransportSocketPrefersEndpointMetadata() throws Exception {
177+
withClusterSnapshot(clusterSnapshot -> {
178+
final ClusterLoadAssignment loadAssignment = loadAssignment(clusterSnapshot);
179+
final LocalityLbEndpoints locality = loadAssignment.getEndpoints(0);
180+
final LbEndpoint lbEndpoint = locality.getLbEndpoints(0);
181+
182+
final TransportSocketSnapshot defaultSocket = clusterSnapshot.transportSocket();
183+
final List<TransportSocketMatchSnapshot> matches = clusterSnapshot.transportSocketMatches();
184+
final TransportSocketMatchSnapshot endpointMatch = matchByName(matches, "endpoint-match");
185+
186+
final TransportSocketSnapshot selected =
187+
TransportSocketMatchUtil.selectTransportSocket(defaultSocket, matches,
188+
lbEndpoint, locality);
189+
assertThat(selected).isSameAs(endpointMatch.transportSocket());
190+
});
191+
}
192+
193+
@Test
194+
void selectTransportSocketFallsBackToLocalityMetadata() throws Exception {
195+
withClusterSnapshot(clusterSnapshot -> {
196+
final ClusterLoadAssignment loadAssignment = loadAssignment(clusterSnapshot);
197+
final LocalityLbEndpoints locality = loadAssignment.getEndpoints(0);
198+
final LbEndpoint lbEndpoint = locality.getLbEndpoints(1);
199+
200+
final TransportSocketSnapshot defaultSocket = clusterSnapshot.transportSocket();
201+
final List<TransportSocketMatchSnapshot> matches = clusterSnapshot.transportSocketMatches();
202+
final TransportSocketMatchSnapshot localityMatch = matchByName(matches, "locality-match");
203+
204+
final TransportSocketSnapshot selected =
205+
TransportSocketMatchUtil.selectTransportSocket(defaultSocket, matches,
206+
lbEndpoint, locality);
207+
assertThat(selected).isSameAs(localityMatch.transportSocket());
208+
});
209+
}
210+
211+
@Test
212+
void selectTransportSocketReturnsDefaultWhenNoMatch() throws Exception {
213+
withClusterSnapshot(clusterSnapshot -> {
214+
final ClusterLoadAssignment loadAssignment = loadAssignment(clusterSnapshot);
215+
final LocalityLbEndpoints locality = loadAssignment.getEndpoints(1);
216+
final LbEndpoint lbEndpoint = locality.getLbEndpoints(0);
217+
218+
final TransportSocketSnapshot defaultSocket = clusterSnapshot.transportSocket();
219+
final List<TransportSocketMatchSnapshot> matches = clusterSnapshot.transportSocketMatches();
220+
221+
final TransportSocketSnapshot selected =
222+
TransportSocketMatchUtil.selectTransportSocket(defaultSocket, matches,
223+
lbEndpoint, locality);
224+
assertThat(selected).isSameAs(defaultSocket);
225+
});
226+
}
227+
228+
private static void withClusterSnapshot(SnapshotConsumer consumer) throws Exception {
229+
final Bootstrap bootstrap =
230+
XdsResourceReader.fromYaml(bootstrapYaml, Bootstrap.class);
231+
try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap)) {
232+
final ListenerRoot listenerRoot = xdsBootstrap.listenerRoot("my-listener");
233+
final AtomicReference<ListenerSnapshot> snapshotRef = new AtomicReference<>();
234+
listenerRoot.addSnapshotWatcher((snapshot, t) -> {
235+
if (snapshot != null) {
236+
snapshotRef.set(snapshot);
237+
}
238+
});
239+
240+
await().untilAsserted(() -> assertThat(snapshotRef.get()).isNotNull());
241+
final ListenerSnapshot listenerSnapshot = snapshotRef.get();
242+
final ClusterSnapshot clusterSnapshot =
243+
listenerSnapshot.routeSnapshot().virtualHostSnapshots().get(0)
244+
.routeEntries().get(0).clusterSnapshot();
245+
consumer.accept(clusterSnapshot);
246+
}
247+
}
248+
249+
private static ClusterLoadAssignment loadAssignment(ClusterSnapshot snapshot) {
250+
return snapshot.xdsResource().resource().getLoadAssignment();
251+
}
252+
253+
private static TransportSocketMatchSnapshot matchByName(List<TransportSocketMatchSnapshot> matches,
254+
String name) {
255+
return matches.stream()
256+
.filter(match -> name.equals(match.xdsResource().getName()))
257+
.findFirst()
258+
.orElseThrow(() -> new IllegalStateException("No match named " + name));
259+
}
260+
261+
private static Struct struct(Map<String, String> map) {
262+
final Struct.Builder builder = Struct.newBuilder();
263+
for (Entry<String, String> entry : map.entrySet()) {
264+
builder.putFields(entry.getKey(),
265+
Value.newBuilder().setStringValue(entry.getValue()).build());
266+
}
267+
return builder.build();
268+
}
269+
270+
@FunctionalInterface
271+
private interface SnapshotConsumer {
272+
void accept(ClusterSnapshot clusterSnapshot) throws Exception;
273+
}
274+
}

it/xds-client/src/test/java/com/linecorp/armeria/xds/it/CertificateValidationContextTest.java

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
import io.envoyproxy.controlplane.server.V3DiscoveryServer;
4848
import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap;
4949
import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.Secret;
50+
import io.envoyproxy.pgv.ValidationException;
5051

5152
class CertificateValidationContextTest {
5253

@@ -181,6 +182,74 @@ void invalidCaCertificateFile(@TempDir File tempDir) throws Exception {
181182
}
182183
}
183184

185+
@Test
186+
void invalidSpkiPinFailsSnapshot() throws Exception {
187+
final String secretYaml =
188+
"""
189+
name: validation-certs
190+
validation_context:
191+
verify_certificate_spki:
192+
- "not-base64"
193+
""";
194+
final Secret secret = XdsResourceReader.fromYaml(secretYaml, Secret.class);
195+
version.incrementAndGet();
196+
cache.setSnapshot(GROUP, Snapshot.create(ImmutableList.of(), ImmutableList.of(), ImmutableList.of(),
197+
ImmutableList.of(), ImmutableList.of(secret),
198+
version.toString()));
199+
200+
final String bootstrapStr = sdsBootstrapYaml.formatted(
201+
server.httpPort(),
202+
certificate1.privateKeyFile().toPath().toString(),
203+
certificate1.certificateFile().toPath().toString());
204+
final Bootstrap bootstrap = XdsResourceReader.fromYaml(bootstrapStr, Bootstrap.class);
205+
206+
final AtomicReference<Throwable> errorRef = new AtomicReference<>();
207+
try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap)) {
208+
xdsBootstrap.listenerRoot("my-listener").addSnapshotWatcher((snapshot, t) -> {
209+
if (t != null) {
210+
errorRef.set(t);
211+
}
212+
});
213+
214+
await().untilAsserted(() -> assertThat(errorRef.get()).isNotNull());
215+
assertThat(errorRef.get()).hasRootCauseInstanceOf(ValidationException.class);
216+
}
217+
}
218+
219+
@Test
220+
void invalidCertHashPinFailsSnapshot() throws Exception {
221+
final String secretYaml =
222+
"""
223+
name: validation-certs
224+
validation_context:
225+
verify_certificate_hash:
226+
- "abc"
227+
""";
228+
final Secret secret = XdsResourceReader.fromYaml(secretYaml, Secret.class);
229+
version.incrementAndGet();
230+
cache.setSnapshot(GROUP, Snapshot.create(ImmutableList.of(), ImmutableList.of(), ImmutableList.of(),
231+
ImmutableList.of(), ImmutableList.of(secret),
232+
version.toString()));
233+
234+
final String bootstrapStr = sdsBootstrapYaml.formatted(
235+
server.httpPort(),
236+
certificate1.privateKeyFile().toPath().toString(),
237+
certificate1.certificateFile().toPath().toString());
238+
final Bootstrap bootstrap = XdsResourceReader.fromYaml(bootstrapStr, Bootstrap.class);
239+
240+
final AtomicReference<Throwable> errorRef = new AtomicReference<>();
241+
try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap)) {
242+
xdsBootstrap.listenerRoot("my-listener").addSnapshotWatcher((snapshot, t) -> {
243+
if (t != null) {
244+
errorRef.set(t);
245+
}
246+
});
247+
248+
await().untilAsserted(() -> assertThat(errorRef.get()).isNotNull());
249+
assertThat(errorRef.get()).hasRootCauseInstanceOf(ValidationException.class);
250+
}
251+
}
252+
184253
@Test
185254
void multipleCaCertificates(@TempDir File tempDir) throws Exception {
186255
final File multiCaFile = new File(tempDir, "multi_ca.pem");

0 commit comments

Comments
 (0)