Skip to content

Commit 87a2533

Browse files
authored
Add support for filter chains (#6820)
Motivation: Listener `filter_chains` and `default_filter_chain` were not resolved into the snapshot tree, so consumers could not access per-filter-chain transport socket or route configurations. The server-side `DownstreamTlsContext` was also not supported, meaning downstream TLS certificates could not be resolved. Additionally, internal extension factories were loaded via SPI and could be overridden by user code. Modifications: **Filter chain resolution** - Added `FilterChainSnapshot` to hold the resolved match criteria, transport socket, and route snapshot for each filter chain, including `toDebugString()`. - Added `ListenerFilterChainFactory` to resolve each `FilterChain` into a `FilterChainSnapshot` by extracting HCM and resolving routes and transport sockets. - Extended `ListenerSnapshot` with `filterChains()`, `defaultFilterChain()`, and updated `routeSnapshot()` to fall back through api_listener → filter chains → default filter chain. Updated `toDebugString()` to delegate to `FilterChainSnapshot.toDebugString()`. **Downstream TLS support** - Added `DownstreamTlsTransportSocketFactory` to support server-side `DownstreamTlsContext` with multiple TLS certificates. - Extracted `resolveValidationContext()` and `resolveTlsCertificates()` from `UpstreamTlsTransportSocketFactory` into `TransportSocketFactory` as shared static methods. `resolveTlsCertificates()` now resolves all certificates (not just the first), returning `SnapshotStream<List<TlsCertificateSnapshot>>`. - Updated `TransportSocketSnapshot` to hold `List<TlsCertificateSnapshot>` instead of `Optional<TlsCertificateSnapshot>`. - Added fail-fast validation in `UpstreamTlsTransportSocketFactory` to reject more than one TLS certificate before creating any streams. **Server-side HTTP filters** - Added `XdsHttpFilter.serviceDecorator()` returning `@Nullable DecoratingHttpServiceFunction` for server-side filter support. - Added `buildDownstreamServerFilter()` in `FilterUtil` to compose server-side filter chains using `DelegatingHttpService` as the terminal service. - Added `RouteEntry.httpService()` to expose the composed server-side `HttpService`. - Added `CachingStream` to deduplicate filter instantiation across routes sharing the same merged filter config. **Extension registry** - Moved internal extension factories (`RouterFilterFactory`, `CredentialInjectorFilterFactory`, `StaticClusterTypeFactory`, `StrictDnsClusterTypeFactory`) from SPI to explicit built-in registration in `XdsExtensionRegistry` with `buildKeepingLast()` precedence (builtin > builder > SPI). Deleted the now-empty SPI service files. - Added `XdsBootstrapBuilder.extensionFactories()` varargs and `Iterable` overloads for registering custom extension factories. Result: - `ListenerSnapshot` now fully resolves `filter_chains` and `default_filter_chain`, enabling filter-chain-level TLS and routing. - Downstream (server-side) TLS supports multiple certificates; upstream enforces at most one. - Internal extension factories can no longer be overridden via SPI. - Server-side HTTP filter chains are resolved and accessible via `RouteEntry.httpService()`.
1 parent c152e49 commit 87a2533

43 files changed

Lines changed: 2000 additions & 380 deletions

Some content is hidden

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

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -492,10 +492,7 @@ void separateConfigSources() throws Exception {
492492
private static final String malformedListenerYaml =
493493
"""
494494
name: my-listener
495-
api_listener:
496-
api_listener:
497-
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager\
498-
.v3.HttpConnectionManager
495+
connection_balance_config: {}
499496
""";
500497

501498
//language=YAML

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

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -344,10 +344,7 @@ public void onUpdate(@Nullable Object snapshot, @Nullable Throwable t) {
344344
static_resources:
345345
listeners:
346346
- name: my-listener
347-
api_listener:
348-
api_listener:
349-
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager\
350-
.v3.HttpConnectionManager
347+
connection_balance_config: {}
351348
""";
352349

353350
public static Stream<Arguments> staticResourceValidationFailure_args() {
@@ -357,7 +354,7 @@ public static Stream<Arguments> staticResourceValidationFailure_args() {
357354
Arguments.of(malformedSecondaryStaticClusterBootstrapYaml,
358355
"name: length must be at least 1 but got: 0"),
359356
Arguments.of(malformedStaticListenerBootstrapYaml,
360-
"stat_prefix: length must be at least 1 but got: 0")
357+
"balance_type: is required")
361358
);
362359
}
363360

@@ -377,10 +374,7 @@ void staticResourceValidationFailure(String bootstrapYaml, String errorMsg) thro
377374
private static final String malformedListenerYaml =
378375
"""
379376
name: my-listener
380-
api_listener:
381-
api_listener:
382-
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager\
383-
.v3.HttpConnectionManager
377+
connection_balance_config: {}
384378
""";
385379

386380
//language=YAML
@@ -525,7 +519,7 @@ private static Stream<Arguments> listenerRootDynamicResourceValidationFailure_ar
525519
ImmutableList.of(), version.toString());
526520
return Stream.of(
527521
Arguments.of(snapshot1, XdsType.LISTENER, "my-listener",
528-
"stat_prefix: length must be at least 1 but got: 0"),
522+
"balance_type: is required"),
529523
Arguments.of(snapshot2, XdsType.ROUTE, "my-route",
530524
"domains: must have at least 1 items"),
531525
Arguments.of(snapshot3, XdsType.CLUSTER, "my-cluster",
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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.it;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.awaitility.Awaitility.await;
21+
22+
import java.util.concurrent.atomic.AtomicReference;
23+
24+
import org.junit.jupiter.api.Test;
25+
26+
import com.linecorp.armeria.xds.FilterChainSnapshot;
27+
import com.linecorp.armeria.xds.ListenerRoot;
28+
import com.linecorp.armeria.xds.ListenerSnapshot;
29+
import com.linecorp.armeria.xds.XdsBootstrap;
30+
31+
import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap;
32+
33+
class FilterChainIntegrationTest {
34+
35+
@Test
36+
void filterChainSnapshots() {
37+
//language=YAML
38+
final Bootstrap bootstrap = XdsResourceReader.fromYaml("""
39+
static_resources:
40+
listeners:
41+
- name: test-listener
42+
filter_chains:
43+
- filters:
44+
- name: envoy.filters.network.http_connection_manager
45+
typed_config:
46+
"@type": type.googleapis.com/envoy.extensions.filters.network\
47+
.http_connection_manager.v3.HttpConnectionManager
48+
stat_prefix: chain1
49+
route_config:
50+
name: route_chain1
51+
virtual_hosts:
52+
- name: vh1
53+
domains: [ "*" ]
54+
routes:
55+
- match:
56+
prefix: /
57+
route:
58+
cluster: test-cluster
59+
http_filters:
60+
- name: envoy.filters.http.router
61+
typed_config:
62+
"@type": type.googleapis.com/envoy.extensions.filters.http\
63+
.router.v3.Router
64+
- filters:
65+
- name: envoy.filters.network.http_connection_manager
66+
typed_config:
67+
"@type": type.googleapis.com/envoy.extensions.filters.network\
68+
.http_connection_manager.v3.HttpConnectionManager
69+
stat_prefix: chain2
70+
route_config:
71+
name: route_chain2
72+
virtual_hosts:
73+
- name: vh2
74+
domains: [ "*" ]
75+
routes:
76+
- match:
77+
prefix: /
78+
route:
79+
cluster: test-cluster
80+
http_filters:
81+
- name: envoy.filters.http.router
82+
typed_config:
83+
"@type": type.googleapis.com/envoy.extensions.filters.http\
84+
.router.v3.Router
85+
default_filter_chain:
86+
filters:
87+
- name: envoy.filters.network.http_connection_manager
88+
typed_config:
89+
"@type": type.googleapis.com/envoy.extensions.filters.network\
90+
.http_connection_manager.v3.HttpConnectionManager
91+
stat_prefix: default_chain
92+
route_config:
93+
name: route_default
94+
virtual_hosts:
95+
- name: vh_default
96+
domains: [ "*" ]
97+
routes:
98+
- match:
99+
prefix: /
100+
route:
101+
cluster: test-cluster
102+
http_filters:
103+
- name: envoy.filters.http.router
104+
typed_config:
105+
"@type": type.googleapis.com/envoy.extensions.filters.http\
106+
.router.v3.Router
107+
clusters:
108+
- name: test-cluster
109+
type: STATIC
110+
load_assignment:
111+
cluster_name: test-cluster
112+
endpoints:
113+
- lb_endpoints:
114+
- endpoint:
115+
address:
116+
socket_address:
117+
address: 127.0.0.1
118+
port_value: 8080
119+
""", Bootstrap.class);
120+
121+
final AtomicReference<ListenerSnapshot> snapshotRef = new AtomicReference<>();
122+
try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap)) {
123+
final ListenerRoot listenerRoot = xdsBootstrap.listenerRoot("test-listener");
124+
listenerRoot.addSnapshotWatcher((snapshot, t) -> {
125+
if (snapshot != null) {
126+
snapshotRef.set(snapshot);
127+
}
128+
});
129+
130+
await().untilAsserted(() -> {
131+
assertThat(snapshotRef.get()).isNotNull();
132+
final ListenerSnapshot snapshot = snapshotRef.get();
133+
134+
// Verify filter chains
135+
assertThat(snapshot.filterChains()).hasSize(2);
136+
137+
final FilterChainSnapshot chain0 = snapshot.filterChains().get(0);
138+
assertThat(chain0.routeSnapshot()).isNotNull();
139+
140+
final FilterChainSnapshot chain1 = snapshot.filterChains().get(1);
141+
assertThat(chain1.routeSnapshot()).isNotNull();
142+
143+
// Verify default filter chain
144+
assertThat(snapshot.defaultFilterChain()).isNotNull();
145+
assertThat(snapshot.defaultFilterChain().routeSnapshot()).isNotNull();
146+
});
147+
}
148+
}
149+
}

0 commit comments

Comments
 (0)