Skip to content

Commit b7c13e5

Browse files
committed
Serve xDS resources that embed Armeria custom filter configs (e.g. Athenz)
Motivation: The xDS control plane parses every stored Listener/Cluster/Route/Endpoint into a proto with a curioswitch MessageMarshaller, which must resolve every google.protobuf.Any it contains (such as an HttpFilter typed_config). The marshaller only registered types under io.envoyproxy.envoy (from java-control-plane) plus a couple of Central Dogma-specific ones. Armeria's xDS filters (e.g. the Athenz access-token filters) are carried as typed_config Any values whose types live in com.linecorp.armeria.xds.athenz and jp.co.lycorp.ftd.athenz.v1, packaged in armeria-xds-api, which Central Dogma did not depend on. Modifications: - Make armeria-xds-api the single provider of io.envoyproxy.envoy types in the :xds module. Result: - xDS resources that embed Armeria custom filter configs, such as the Athenz access-token filters, can now be created, stored and served by the control plane.
1 parent f8a443c commit b7c13e5

6 files changed

Lines changed: 173 additions & 15 deletions

File tree

dependencies.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# If its classes are exposed in Javadoc, update offline links as well.
44
#
55
[versions]
6-
armeria = "1.40.0"
6+
armeria = "1.41.0"
77
assertj = "3.27.7"
88
awaitility = "4.3.0"
99
bouncycastle = "1.84"

xds/build.gradle

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,23 @@ dependencies {
55
implementation libs.kubernetes.client.impl
66

77
implementation libs.armeria.grpc
8-
implementation libs.controlplane.cache
9-
implementation libs.controlplane.server
8+
// The Envoy proto types (io.envoyproxy.envoy.*) are provided by armeria-xds-api rather than
9+
// java-control-plane's own 'api' artifact, so Central Dogma parses and serves resources against the
10+
// same (Armeria-customized, evolving) schema that the Armeria xDS stack uses. java-control-plane's
11+
// cache/server run against these classes; their transitive controlplane:api is excluded to keep a
12+
// single io.envoyproxy.envoy provider on the classpath (both jars ship the same FQCNs).
13+
implementation libs.armeria.xds.api
14+
implementation(libs.controlplane.cache) {
15+
exclude group: 'io.envoyproxy.controlplane', module: 'api'
16+
}
17+
implementation(libs.controlplane.server) {
18+
exclude group: 'io.envoyproxy.controlplane', module: 'api'
19+
}
1020

1121
implementation libs.reflections
1222

1323
testImplementation libs.armeria.junit5
14-
testImplementation (libs.armeria.xds) {
15-
exclude group: 'com.linecorp.armeria', module: 'armeria-xds-api'
16-
}
24+
testImplementation libs.armeria.xds
1725
testImplementation libs.kubernetes.server.mock
1826
testImplementation libs.kubernetes.junit.jupiter
1927
testImplementation libs.logback15

xds/src/main/java/com/linecorp/centraldogma/xds/internal/XdsResourceManager.java

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,6 @@
5757
import com.linecorp.centraldogma.xds.endpoint.v1.LocalityLbEndpoint;
5858
import com.linecorp.centraldogma.xds.k8s.v1.KubernetesEndpointAggregator;
5959

60-
import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager;
61-
6260
public final class XdsResourceManager {
6361

6462
private static final Logger logger = LoggerFactory.getLogger(XdsResourceManager.class);
@@ -76,24 +74,40 @@ public final class XdsResourceManager {
7674

7775
public static final MessageMarshaller JSON_MESSAGE_MARSHALLER;
7876

77+
// Packages of proto types that xDS resources may embed in a google.protobuf.Any (e.g. an HttpFilter
78+
// typed_config) but that do not live under io.envoyproxy.envoy, so the Envoy scan does not pick them up.
79+
// These are the Armeria xDS filter configs and their metadata messages, provided by armeria-xds-api.
80+
private static final ImmutableList<String> CUSTOM_TYPE_PACKAGES = ImmutableList.of(
81+
"com.linecorp.armeria.xds.athenz",
82+
"jp.co.lycorp.ftd.athenz.v1");
83+
7984
static {
8085
final MessageMarshaller.Builder builder =
8186
MessageMarshaller.builder().omittingInsignificantWhitespace(true);
8287
// These Central Dogma-specific types are not in the io.envoyproxy.envoy package, so they
83-
// must be registered explicitly; envoyExtension() does not pick them up.
88+
// must be registered explicitly; the io.envoyproxy.envoy scan does not pick them up.
8489
builder.register(KubernetesEndpointAggregator.getDefaultInstance())
8590
.register(LocalityLbEndpoint.getDefaultInstance());
86-
envoyExtension(builder);
91+
// Envoy types use java_multiple_files, so every message is a top-level class.
92+
registerPackage(builder, "io.envoyproxy.envoy", false);
93+
// The custom (non-Envoy) protos do not set java_multiple_files, so their messages are nested.
94+
for (String customTypePackage : CUSTOM_TYPE_PACKAGES) {
95+
registerPackage(builder, customTypePackage, true);
96+
}
8797
JSON_MESSAGE_MARSHALLER = builder.build();
8898
}
8999

90-
private static void envoyExtension(MessageMarshaller.Builder builder) {
100+
private static void registerPackage(MessageMarshaller.Builder builder, String packageName,
101+
boolean includeNested) {
91102
final Reflections reflections = new Reflections(
92-
"io.envoyproxy.envoy", HttpConnectionManager.class.getClassLoader(),
103+
packageName, XdsResourceManager.class.getClassLoader(),
93104
new SubTypesScanner(true));
94105
reflections.getSubTypesOf(GeneratedMessageV3.class)
95106
.stream()
96-
.filter(c -> !c.getName().contains("$")) // exclude inner classes
107+
// For java_multiple_files packages, a nested ('$') class is never a registrable message.
108+
// For custom packages the messages themselves are nested, so nested classes are kept and
109+
// the non-message ones (Builders, enums) are filtered out below by getDefaultInstance check.
110+
.filter(c -> includeNested || !c.getName().contains("$"))
97111
.filter(XdsResourceManager::hasGetDefaultInstanceMethod)
98112
.forEach(c -> {
99113
// register() does not throw; build() does. A test build per class is needed
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
package com.linecorp.centraldogma.xds.internal;
17+
18+
import static com.linecorp.centraldogma.xds.internal.XdsResourceManager.JSON_MESSAGE_MARSHALLER;
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
21+
import org.junit.jupiter.api.Test;
22+
23+
import com.google.protobuf.Any;
24+
25+
import com.linecorp.armeria.xds.athenz.AthenzFilterConfig.AccessTokenConstraintConfig;
26+
import com.linecorp.centraldogma.internal.Yaml;
27+
28+
import io.envoyproxy.envoy.config.listener.v3.ApiListener;
29+
import io.envoyproxy.envoy.config.listener.v3.Listener;
30+
import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager;
31+
import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpFilter;
32+
import jp.co.lycorp.ftd.athenz.v1.AthenzAccessToken.AccessTokenConstraint;
33+
34+
class XdsAthenzResourceParseTest {
35+
36+
private static Listener athenzListener() {
37+
// An inbound Athenz constraint filter config, packed as the http filter typed_config Any.
38+
final AccessTokenConstraintConfig constraintConfig =
39+
AccessTokenConstraintConfig.newBuilder()
40+
.setZtsClusterName("zts-cluster")
41+
.setAccessTokenConstraint(
42+
AccessTokenConstraint.newBuilder()
43+
.setConstraintDomain("my.domain")
44+
.setSyntaxVersion(1))
45+
.build();
46+
final HttpConnectionManager manager =
47+
HttpConnectionManager.newBuilder()
48+
.setStatPrefix("ingress_http")
49+
.addHttpFilters(
50+
HttpFilter.newBuilder()
51+
.setName("armeria.xds.athenz.access_token_constraint")
52+
.setTypedConfig(Any.pack(constraintConfig)))
53+
.build();
54+
return Listener.newBuilder()
55+
.setName("groups/foo/listeners/athenz")
56+
.setApiListener(ApiListener.newBuilder().setApiListener(Any.pack(manager)))
57+
.build();
58+
}
59+
60+
@Test
61+
void athenzTypedConfigRoundTrips() throws Exception {
62+
final Listener listener = athenzListener();
63+
64+
// Serialize (as the serving/read path does) — the marshaller must resolve the Athenz Any type.
65+
final String yaml = XdsResourceManager.toYamlBodyString(listener);
66+
assertThat(yaml).contains("type.googleapis.com/armeria.xds.athenz.AccessTokenConstraintConfig");
67+
68+
// Parse it back (as the create/update and control-plane paths do).
69+
final Listener.Builder builder = Listener.newBuilder();
70+
JSON_MESSAGE_MARSHALLER.mergeValue(Yaml.readTree(yaml).traverse(), builder);
71+
assertThat(builder.build()).isEqualTo(listener);
72+
}
73+
74+
@Test
75+
void athenzFilterConfigUnpacksFromParsedResource() throws Exception {
76+
final Listener parsed = XdsResourceManager.parseYaml(
77+
XdsResourceManager.toYamlBodyString(athenzListener()), Listener.newBuilder());
78+
final HttpConnectionManager manager =
79+
parsed.getApiListener().getApiListener().unpack(HttpConnectionManager.class);
80+
final Any typedConfig = manager.getHttpFilters(0).getTypedConfig();
81+
assertThat(typedConfig.getTypeUrl())
82+
.isEqualTo("type.googleapis.com/armeria.xds.athenz.AccessTokenConstraintConfig");
83+
final AccessTokenConstraintConfig constraintConfig =
84+
typedConfig.unpack(AccessTokenConstraintConfig.class);
85+
assertThat(constraintConfig.getZtsClusterName()).isEqualTo("zts-cluster");
86+
assertThat(constraintConfig.getAccessTokenConstraint().getConstraintDomain()).isEqualTo("my.domain");
87+
}
88+
}

xds/src/test/java/com/linecorp/centraldogma/xds/internal/XdsTestUtil.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,10 +222,15 @@ static Cluster staticCluster(String clusterName, ClusterLoadAssignment loadAssig
222222
}
223223

224224
public static Listener exampleListener(String listenerName, String routeName, String statPrefix) {
225-
final HttpConnectionManager manager = httpConnectionManager(routeName, rdsConfigSource());
225+
// Apply statPrefix to the HttpConnectionManager (a field the Armeria xDS client marks as supported)
226+
// rather than to Listener.stat_prefix, which the client does not mark supported and would reject
227+
// during validation.
228+
final HttpConnectionManager manager =
229+
httpConnectionManager(routeName, rdsConfigSource()).toBuilder()
230+
.setStatPrefix(statPrefix)
231+
.build();
226232
return Listener.newBuilder()
227233
.setName(listenerName)
228-
.setStatPrefix(statPrefix)
229234
.setApiListener(ApiListener.newBuilder()
230235
.setApiListener(Any.pack(manager)))
231236
.build();

xds/src/test/java/com/linecorp/centraldogma/xds/listener/v1/XdsListenerServiceTest.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,20 @@
4141
import com.linecorp.armeria.common.HttpMethod;
4242
import com.linecorp.armeria.common.HttpStatus;
4343
import com.linecorp.armeria.common.RequestHeaders;
44+
import com.linecorp.armeria.xds.athenz.AthenzFilterConfig.AccessTokenConstraintConfig;
4445
import com.linecorp.centraldogma.internal.Yaml;
4546
import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension;
4647

4748
import io.envoyproxy.controlplane.cache.Resources.V3;
49+
import io.envoyproxy.envoy.config.listener.v3.ApiListener;
4850
import io.envoyproxy.envoy.config.listener.v3.Listener;
51+
import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager;
52+
import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpFilter;
4953
import io.envoyproxy.envoy.service.discovery.v3.DiscoveryRequest;
5054
import io.envoyproxy.envoy.service.discovery.v3.DiscoveryResponse;
5155
import io.envoyproxy.envoy.service.listener.v3.ListenerDiscoveryServiceGrpc.ListenerDiscoveryServiceStub;
5256
import io.grpc.stub.StreamObserver;
57+
import jp.co.lycorp.ftd.athenz.v1.AthenzAccessToken.AccessTokenConstraint;
5358

5459
class XdsListenerServiceTest {
5560

@@ -87,6 +92,44 @@ void createListenerViaHttp() throws Exception {
8792
checkResourceViaDiscoveryRequest(actualListener, listenerName, true);
8893
}
8994

95+
@Test
96+
void createAthenzListenerViaHttp() throws Exception {
97+
// A listener whose HttpConnectionManager carries an Athenz inbound filter config in an HttpFilter
98+
// typed_config Any. This exercises both parsing (the create API) and serving (LDS) of a resource that
99+
// embeds a custom, non-io.envoyproxy.envoy proto type provided by armeria-xds-api.
100+
final AccessTokenConstraintConfig constraintConfig =
101+
AccessTokenConstraintConfig.newBuilder()
102+
.setZtsClusterName("zts-cluster")
103+
.setAccessTokenConstraint(
104+
AccessTokenConstraint.newBuilder()
105+
.setConstraintDomain("my.domain")
106+
.setSyntaxVersion(1))
107+
.build();
108+
final HttpConnectionManager manager =
109+
HttpConnectionManager.newBuilder()
110+
.setStatPrefix("ingress_http")
111+
.addHttpFilters(
112+
HttpFilter.newBuilder()
113+
.setName("armeria.xds.athenz.access_token_constraint")
114+
.setTypedConfig(Any.pack(constraintConfig)))
115+
.build();
116+
final Listener listener =
117+
Listener.newBuilder()
118+
.setName("this_listener_name_will_be_ignored_and_replaced")
119+
.setApiListener(ApiListener.newBuilder().setApiListener(Any.pack(manager)))
120+
.build();
121+
122+
final AggregatedHttpResponse response =
123+
createListener("groups/foo", "athenz-listener.1", listener, dogma.httpClient());
124+
assertOk(response);
125+
final Listener.Builder listenerBuilder = Listener.newBuilder();
126+
JSON_MESSAGE_MARSHALLER.mergeValue(Yaml.readTree(response.contentUtf8()).traverse(), listenerBuilder);
127+
final Listener actualListener = listenerBuilder.build();
128+
final String listenerName = "groups/foo/listeners/athenz-listener.1";
129+
assertThat(actualListener).isEqualTo(listener.toBuilder().setName(listenerName).build());
130+
checkResourceViaDiscoveryRequest(actualListener, listenerName, true);
131+
}
132+
90133
private static void assertOk(AggregatedHttpResponse response) {
91134
assertThat(response.status()).isSameAs(HttpStatus.OK);
92135
}

0 commit comments

Comments
 (0)