From 7149a6aead7fb908bc35e412aae21fc6a3dd853e Mon Sep 17 00:00:00 2001 From: jrhee17 Date: Thu, 30 Jul 2026 11:37:44 +0900 Subject: [PATCH 1/2] minimal impl --- dependencies.toml | 10 ++ examples/spring-cloud-config-xds/build.gradle | 15 ++ .../xds/cloudconfig/client/ClientMain.java | 49 ++++++ .../cloudconfig/client/XdsClientConfig.java | 22 +++ .../cloudconfig/server/ConfigServerMain.java | 26 +++ .../resources/config-repo/application.yml | 35 ++++ .../resources/config/application-client.yml | 6 + .../resources/config/application-server.yml | 9 + .../SpringCloudConfigXdsExampleTest.java | 128 ++++++++++++++ settings.gradle | 2 + spring/boot4-xds/build.gradle | 9 + .../spring/xds/SpringConfigSourceFactory.java | 166 ++++++++++++++++++ .../xds/SpringXdsAutoConfiguration.java | 125 +++++++++++++ .../SpringXdsTypeRegistryPackageProvider.java | 34 ++++ .../spring/xds/YamlPropertySourceFactory.java | 42 +++++ .../armeria/spring/xds/package-info.java | 23 +++ .../armeria/xds/default-bootstrap.yml | 16 ++ ...armeria.xds.XdsTypeRegistryPackageProvider | 17 ++ ...ot.autoconfigure.AutoConfiguration.imports | 17 ++ .../xds/SpringXdsCustomBootstrapTest.java | 69 ++++++++ .../spring/xds/SpringXdsYamlFileTest.java | 78 ++++++++ .../application-xds-custom-bootstrap-test.yml | 25 +++ .../resources/application-xds-file-test.yml | 35 ++++ .../xds/spring/spring_config_source.proto | 21 +++ .../com/linecorp/armeria/xds/XdsType.java | 33 +++- 25 files changed, 1005 insertions(+), 7 deletions(-) create mode 100644 examples/spring-cloud-config-xds/build.gradle create mode 100644 examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/client/ClientMain.java create mode 100644 examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/client/XdsClientConfig.java create mode 100644 examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/server/ConfigServerMain.java create mode 100644 examples/spring-cloud-config-xds/src/main/resources/config-repo/application.yml create mode 100644 examples/spring-cloud-config-xds/src/main/resources/config/application-client.yml create mode 100644 examples/spring-cloud-config-xds/src/main/resources/config/application-server.yml create mode 100644 examples/spring-cloud-config-xds/src/test/java/example/springframework/boot/xds/cloudconfig/SpringCloudConfigXdsExampleTest.java create mode 100644 spring/boot4-xds/build.gradle create mode 100644 spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringConfigSourceFactory.java create mode 100644 spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringXdsAutoConfiguration.java create mode 100644 spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringXdsTypeRegistryPackageProvider.java create mode 100644 spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/YamlPropertySourceFactory.java create mode 100644 spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/package-info.java create mode 100644 spring/boot4-xds/src/main/resources/META-INF/armeria/xds/default-bootstrap.yml create mode 100644 spring/boot4-xds/src/main/resources/META-INF/services/com.linecorp.armeria.xds.XdsTypeRegistryPackageProvider create mode 100644 spring/boot4-xds/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 spring/boot4-xds/src/test/java/com/linecorp/armeria/spring/xds/SpringXdsCustomBootstrapTest.java create mode 100644 spring/boot4-xds/src/test/java/com/linecorp/armeria/spring/xds/SpringXdsYamlFileTest.java create mode 100644 spring/boot4-xds/src/test/resources/application-xds-custom-bootstrap-test.yml create mode 100644 spring/boot4-xds/src/test/resources/application-xds-file-test.yml create mode 100644 xds-api/src/main/proto/armeria/xds/spring/spring_config_source.proto diff --git a/dependencies.toml b/dependencies.toml index 1bf2d883806..0f249fa8184 100644 --- a/dependencies.toml +++ b/dependencies.toml @@ -174,6 +174,8 @@ spring7 = "7.0.8" spring-boot2 = "2.7.18" spring-boot3 = "3.5.16" spring-boot4 = "4.1.0" +spring-cloud-config-server = "5.0.4" +spring-cloud-context = "5.0.2" testcontainers = "2.0.5" thrift09 = { strictly = "0.9.3-1" } thrift012 = { strictly = "0.12.0" } @@ -1422,6 +1424,14 @@ version.ref = "spring-boot4" module = "org.springframework.boot:spring-boot-tomcat" version.ref = "spring-boot4" +[libraries.spring-cloud-config-server] +module = "org.springframework.cloud:spring-cloud-config-server" +version.ref = "spring-cloud-config-server" + +[libraries.spring-cloud-context] +module = "org.springframework.cloud:spring-cloud-context" +version.ref = "spring-cloud-context" + [libraries.testcontainers-core] module = "org.testcontainers:testcontainers" [libraries.testcontainers-consul] diff --git a/examples/spring-cloud-config-xds/build.gradle b/examples/spring-cloud-config-xds/build.gradle new file mode 100644 index 00000000000..f69d0fb6077 --- /dev/null +++ b/examples/spring-cloud-config-xds/build.gradle @@ -0,0 +1,15 @@ +dependencies { + implementation project(':spring:boot4-xds') + implementation libs.spring.cloud.config.server + testImplementation libs.spring.boot4.starter.test +} + +tasks.register('runConfigServer', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'example.springframework.boot.xds.cloudconfig.server.ConfigServerMain' +} + +tasks.register('runClient', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'example.springframework.boot.xds.cloudconfig.client.ClientMain' +} diff --git a/examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/client/ClientMain.java b/examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/client/ClientMain.java new file mode 100644 index 00000000000..215c727f919 --- /dev/null +++ b/examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/client/ClientMain.java @@ -0,0 +1,49 @@ +package example.springframework.boot.xds.cloudconfig.client; + +import java.util.Map; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; + +/** + * A Spring Boot client application that loads xDS resources from a + * Spring Cloud Config Server. Properties are loaded from {@code application-client.yml}. + * + *

Exposes a {@code GET /relay?path=...} endpoint that forwards the request to + * the upstream resolved by the xDS listener and returns the response. + */ +@SpringBootApplication +@RestController +public class ClientMain { + + private final WebClient xdsWebClient; + + public ClientMain(WebClient xdsWebClient) { + this.xdsWebClient = xdsWebClient; + } + + public static void main(String[] args) { + createApplication().run(args); + } + + public static SpringApplication createApplication() { + final SpringApplication app = new SpringApplication(ClientMain.class); + app.setAdditionalProfiles("client"); + // spring-cloud-config-server on the classpath disables the config client + // via ConfigServerBootstrapApplicationListener; re-enable it explicitly. + app.setDefaultProperties(Map.of("spring.cloud.config.enabled", "true")); + return app; + } + + @GetMapping("/relay") + String relay(@RequestParam(defaultValue = "/actuator/health") String path) { + final AggregatedHttpResponse response = xdsWebClient.get(path).aggregate().join(); + return response.status() + "\n" + response.contentUtf8(); + } +} diff --git a/examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/client/XdsClientConfig.java b/examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/client/XdsClientConfig.java new file mode 100644 index 00000000000..55393e54c61 --- /dev/null +++ b/examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/client/XdsClientConfig.java @@ -0,0 +1,22 @@ +package example.springframework.boot.xds.cloudconfig.client; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.xds.XdsBootstrap; +import com.linecorp.armeria.xds.client.endpoint.XdsHttpPreprocessor; + +@Configuration +class XdsClientConfig { + + @Bean + XdsHttpPreprocessor xdsHttpPreprocessor(XdsBootstrap xdsBootstrap) { + return XdsHttpPreprocessor.ofListener("test-listener", xdsBootstrap); + } + + @Bean + WebClient xdsWebClient(XdsHttpPreprocessor xdsHttpPreprocessor) { + return WebClient.of(xdsHttpPreprocessor); + } +} diff --git a/examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/server/ConfigServerMain.java b/examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/server/ConfigServerMain.java new file mode 100644 index 00000000000..243a8b7c295 --- /dev/null +++ b/examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/server/ConfigServerMain.java @@ -0,0 +1,26 @@ +package example.springframework.boot.xds.cloudconfig.server; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.config.server.EnableConfigServer; + +import com.linecorp.armeria.spring.xds.SpringXdsAutoConfiguration; + +/** + * A Spring Cloud Config Server that serves xDS cluster definitions from a native + * (file-based) repository. + */ +@EnableConfigServer +@SpringBootApplication(exclude = SpringXdsAutoConfiguration.class) +public class ConfigServerMain { + + public static void main(String[] args) { + createApplication().run(args); + } + + public static SpringApplication createApplication() { + final SpringApplication app = new SpringApplication(ConfigServerMain.class); + app.setAdditionalProfiles("server", "native"); + return app; + } +} diff --git a/examples/spring-cloud-config-xds/src/main/resources/config-repo/application.yml b/examples/spring-cloud-config-xds/src/main/resources/config-repo/application.yml new file mode 100644 index 00000000000..aaaf3fd82fa --- /dev/null +++ b/examples/spring-cloud-config-xds/src/main/resources/config-repo/application.yml @@ -0,0 +1,35 @@ +armeria: + xds: + test-listener: | + name: test-listener + api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + codec_type: AUTO + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: / + route: + cluster: test-cluster + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + test-cluster: | + name: test-cluster + type: STATIC + load_assignment: + cluster_name: test-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: 8888 diff --git a/examples/spring-cloud-config-xds/src/main/resources/config/application-client.yml b/examples/spring-cloud-config-xds/src/main/resources/config/application-client.yml new file mode 100644 index 00000000000..7f05570ae5f --- /dev/null +++ b/examples/spring-cloud-config-xds/src/main/resources/config/application-client.yml @@ -0,0 +1,6 @@ +spring: + config: + "import": "configserver:" + cloud: + config: + uri: http://localhost:8888 diff --git a/examples/spring-cloud-config-xds/src/main/resources/config/application-server.yml b/examples/spring-cloud-config-xds/src/main/resources/config/application-server.yml new file mode 100644 index 00000000000..484b05b838c --- /dev/null +++ b/examples/spring-cloud-config-xds/src/main/resources/config/application-server.yml @@ -0,0 +1,9 @@ +server: + port: 8888 + +spring: + cloud: + config: + server: + native: + searchLocations: classpath:/config-repo/ diff --git a/examples/spring-cloud-config-xds/src/test/java/example/springframework/boot/xds/cloudconfig/SpringCloudConfigXdsExampleTest.java b/examples/spring-cloud-config-xds/src/test/java/example/springframework/boot/xds/cloudconfig/SpringCloudConfigXdsExampleTest.java new file mode 100644 index 00000000000..849f0f24a67 --- /dev/null +++ b/examples/spring-cloud-config-xds/src/test/java/example/springframework/boot/xds/cloudconfig/SpringCloudConfigXdsExampleTest.java @@ -0,0 +1,128 @@ +package example.springframework.boot.xds.cloudconfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.context.WebServerApplicationContext; +import org.springframework.cloud.context.environment.EnvironmentChangeEvent; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.xds.ClusterSnapshot; +import com.linecorp.armeria.xds.XdsBootstrap; + +import example.springframework.boot.xds.cloudconfig.client.ClientMain; +import example.springframework.boot.xds.cloudconfig.server.ConfigServerMain; + +@SpringBootTest( + classes = ClientMain.class, + properties = { + "spring.config.import=configserver:", + "spring.cloud.config.enabled=true" + }) +class SpringCloudConfigXdsExampleTest { + + private static ConfigurableApplicationContext server; + private static int configPort; + + @BeforeAll + static void startConfigServer() { + server = ConfigServerMain.createApplication().run("--server.port=0"); + + configPort = ((WebServerApplicationContext) server).getWebServer().getPort(); + System.setProperty("spring.cloud.config.uri", "http://localhost:" + configPort); + } + + @AfterAll + static void stopConfigServer() { + System.clearProperty("spring.cloud.config.uri"); + if (server != null) { + server.close(); + } + } + + @Autowired + WebClient xdsWebClient; + + @Autowired + XdsBootstrap xdsBootstrap; + + @Autowired + ConfigurableEnvironment environment; + + @Autowired + ApplicationContext applicationContext; + + @Test + void webClientViaXdsPreprocessor() { + // Verify the initial cluster snapshot loaded from config server (port 8888) + final AtomicReference snapshotRef = new AtomicReference<>(); + xdsBootstrap.clusterRoot("test-cluster") + .addSnapshotWatcher((snapshot, error) -> { + if (snapshot != null) { + snapshotRef.set(snapshot); + } + }); + await().untilAsserted(() -> { + assertThat(snapshotRef.get()).isNotNull(); + assertThat(endpointPort(snapshotRef.get())).isEqualTo(8888); + }); + + // Override the cluster endpoint to point at the config server's actual port + environment.getPropertySources() + .addFirst(new MapPropertySource("test", + Map.of("armeria.xds.test-cluster", clusterYaml(configPort)))); + applicationContext.publishEvent( + new EnvironmentChangeEvent(Set.of("armeria.xds.test-cluster"))); + + // Wait for the xDS update to propagate + await().untilAsserted(() -> + assertThat(endpointPort(snapshotRef.get())).isEqualTo(configPort)); + + // Call the config server's actuator health endpoint via the xDS-resolved client + final AggregatedHttpResponse response = + xdsWebClient.get("/actuator/health").aggregate().join(); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + } + + private static int endpointPort(ClusterSnapshot snapshot) { + return snapshot.xdsResource().resource() + .getLoadAssignment() + .getEndpoints(0) + .getLbEndpoints(0) + .getEndpoint() + .getAddress() + .getSocketAddress() + .getPortValue(); + } + + private static String clusterYaml(int port) { + return """ + name: test-cluster + type: STATIC + load_assignment: + cluster_name: test-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: %d + """.formatted(port); + } +} diff --git a/settings.gradle b/settings.gradle index c7c2db93ca9..7fa943cc188 100644 --- a/settings.gradle +++ b/settings.gradle @@ -189,6 +189,7 @@ includeWithFlags ':spring:boot4-autoconfigure', 'java17', 'publish', 'r includeWithFlags ':spring:boot4-starter', 'java17', 'publish', 'relocate', 'no_aggregation' includeWithFlags ':spring:boot4-webflux-autoconfigure', 'java17', 'publish', 'relocate' includeWithFlags ':spring:boot4-webflux-starter', 'java17', 'publish', 'relocate', 'no_aggregation' +includeWithFlags ':spring:boot4-xds', 'java17', 'publish', 'relocate' includeWithFlags ':spring:spring7', 'java17', 'publish', 'relocate' includeWithFlags ':dropwizard2', 'java', 'publish', 'relocate' @@ -332,6 +333,7 @@ includeWithFlags ':examples:spring-boot-minimal', 'java17' includeWithFlags ':examples:spring-boot-minimal-kotlin', 'java17', 'kotlin' includeWithFlags ':examples:spring-boot-tomcat', 'java17' includeWithFlags ':examples:spring-boot-webflux', 'java17' +includeWithFlags ':examples:spring-cloud-config-xds', 'java17' includeWithFlags ':examples:static-files', 'java11' includeWithFlags ':examples:thrift', 'java11' includeWithFlags ':examples:tutorials:grpc-tutorial', 'java11' diff --git a/spring/boot4-xds/build.gradle b/spring/boot4-xds/build.gradle new file mode 100644 index 00000000000..07520e92c56 --- /dev/null +++ b/spring/boot4-xds/build.gradle @@ -0,0 +1,9 @@ +dependencies { + api project(':xds') + api libs.spring.boot4.autoconfigure + api libs.spring.cloud.context + + annotationProcessor libs.spring.boot4.configuration.processor + + testImplementation libs.spring.boot4.starter.test +} diff --git a/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringConfigSourceFactory.java b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringConfigSourceFactory.java new file mode 100644 index 00000000000..80a695cc642 --- /dev/null +++ b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringConfigSourceFactory.java @@ -0,0 +1,166 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.spring.xds; + +import static java.util.Objects.requireNonNull; + +import java.util.EnumMap; +import java.util.Map; + +import org.springframework.core.env.Environment; + +import com.google.common.collect.ImmutableMap; +import com.google.protobuf.Any; +import com.google.protobuf.GeneratedMessageV3; + +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.xds.SnapshotWatcher; +import com.linecorp.armeria.xds.XdsResourceReader; +import com.linecorp.armeria.xds.XdsType; +import com.linecorp.armeria.xds.configsource.InterestedResources; +import com.linecorp.armeria.xds.configsource.SotwConfigSourceSubscriptionFactory; +import com.linecorp.armeria.xds.filter.FactoryContext; +import com.linecorp.armeria.xds.stream.RefCountedStream; +import com.linecorp.armeria.xds.stream.SnapshotStream; +import com.linecorp.armeria.xds.stream.Subscription; + +import io.envoyproxy.envoy.config.core.v3.ConfigSource; +import io.envoyproxy.envoy.service.discovery.v3.DiscoveryResponse; + +/** + * A {@link SotwConfigSourceSubscriptionFactory} that reads xDS resources from + * Spring {@link Environment} properties. Each resource name is mapped to a + * property key of the form {@code .}, and the property + * value is parsed as the type corresponding to the subscribed {@link XdsType}. + * + *

The property key prefix is configured via a {@link SpringConfigSource} + * packed into the {@code typed_config} field of the bootstrap's + * {@code custom_config_source}. The default prefix is {@code armeria.xds.}. + * + *

Call {@link #refresh()} to re-read properties and push updated resources + * to subscribers (e.g. from a Spring Cloud Config {@code EnvironmentChangeEvent}). + * + * @see SpringXdsAutoConfiguration + */ +@UnstableApi +public final class SpringConfigSourceFactory implements SotwConfigSourceSubscriptionFactory { + + private static final String NAME = "armeria.config_source.spring"; + + private static final Object SIGNAL = new Object(); + + private final Environment environment; + private final RefreshSignal refreshSignal = new RefreshSignal(); + + /** + * Creates a new factory backed by the given Spring {@link Environment}. + */ + public static SpringConfigSourceFactory of(Environment environment) { + return new SpringConfigSourceFactory(environment); + } + + private SpringConfigSourceFactory(Environment environment) { + this.environment = requireNonNull(environment, "environment"); + } + + @Override + public String name() { + return NAME; + } + + @Override + public SnapshotStream create(ConfigSource configSource, + FactoryContext factoryContext, + SnapshotStream interestedResources) { + final SpringConfigSource springConfigSource = + factoryContext.validator().unpack(configSource.getCustomConfigSource().getTypedConfig(), + SpringConfigSource.class); + final String rawPrefix = springConfigSource.getPrefix(); + if (rawPrefix.isEmpty() || ".".equals(rawPrefix)) { + throw new IllegalArgumentException( + "SpringConfigSource 'prefix' must not be empty. " + + "Set a prefix such as 'armeria.xds.' in the bootstrap typed_config."); + } + final String prefix = rawPrefix.endsWith(".") ? rawPrefix : rawPrefix + '.'; + final Map accumulated = new EnumMap<>(XdsType.class); + final SnapshotStream> allInterests = + interestedResources.map(interest -> { + accumulated.put(interest.type(), interest); + return ImmutableMap.copyOf(accumulated); + }); + final SnapshotStream refresh = refreshSignal.rescheduleEventsOn(factoryContext.eventLoop()); + return SnapshotStream.combineLatest(allInterests, refresh, (interests, signal) -> interests) + .switchMapEager(interests -> + buildResponseStream(environment, prefix, interests)); + } + + /** + * Re-reads all active properties from the {@link Environment} and pushes + * updated resources to subscribers. + */ + public void refresh() { + refreshSignal.push(); + } + + private static SnapshotStream buildResponseStream( + Environment environment, String prefix, + Map interests) { + return watcher -> { + for (InterestedResources interested : interests.values()) { + try { + final DiscoveryResponse response = buildResponse(environment, prefix, interested); + watcher.onUpdate(response, null); + } catch (Exception e) { + watcher.onUpdate(null, e); + } + } + return Subscription.noop(); + }; + } + + private static DiscoveryResponse buildResponse(Environment environment, String prefix, + InterestedResources interested) { + final XdsType type = interested.type(); + final String typeUrl = type.typeUrl(); + final Class protoClass = type.resourceClass(); + final DiscoveryResponse.Builder builder = DiscoveryResponse.newBuilder() + .setTypeUrl(typeUrl); + for (String name : interested.resourceNames()) { + final String propertyKey = prefix + name; + final String yaml = environment.getProperty(propertyKey); + if (yaml == null || yaml.isBlank()) { + throw new IllegalArgumentException("Property '" + propertyKey + "' is empty or not set"); + } + final GeneratedMessageV3 resource = XdsResourceReader.from(yaml, protoClass); + builder.addResources(Any.pack(resource)); + } + return builder.build(); + } + + static final class RefreshSignal extends RefCountedStream { + + @Override + protected Subscription onStart(SnapshotWatcher watcher) { + emit(SIGNAL, null); + return Subscription.noop(); + } + + void push() { + emit(SIGNAL, null); + } + } +} diff --git a/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringXdsAutoConfiguration.java b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringXdsAutoConfiguration.java new file mode 100644 index 00000000000..96280e82867 --- /dev/null +++ b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringXdsAutoConfiguration.java @@ -0,0 +1,125 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.spring.xds; + +import java.util.List; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.cloud.context.environment.EnvironmentChangeEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.PropertySource; +import org.springframework.core.env.Environment; + +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.xds.XdsBootstrap; +import com.linecorp.armeria.xds.XdsExtensionFactory; +import com.linecorp.armeria.xds.XdsResourceReader; + +import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap; + +/** + * Spring Boot auto-configuration that sets up xDS resource loading from + * Spring {@link Environment} properties and automatically refreshes on + * {@link EnvironmentChangeEvent}. + * + *

Note: YAML configuration files ({@code application.yml}) are recommended + * over {@code .properties} files because xDS resource values are multi-line YAML + * that cannot be represented correctly in {@code .properties} format. + * + *

xDS resources

+ * + *

Each xDS resource is stored as a separate property with the key + * {@code armeria.xds.} containing the resource YAML + * (without {@code @type} wrappers — the type is inferred from the + * subscription context). + * + *

Example: + *

{@code
+ * armeria:
+ *   xds:
+ *     my-cluster: |
+ *       name: my-cluster
+ *       type: STATIC
+ *       load_assignment:
+ *         cluster_name: my-cluster
+ *         endpoints:
+ *           - lb_endpoints:
+ *               - endpoint:
+ *                   address:
+ *                     socket_address:
+ *                       address: 127.0.0.1
+ *                       port_value: 8080
+ * }
+ * + *

Bootstrap

+ * + *

The xDS {@link Bootstrap} is created from the {@code armeria.xds.bootstrap} property. + * If not set, a default bootstrap is loaded from + * {@code META-INF/armeria/xds/default-bootstrap.yml}, which configures both LDS and CDS + * to use {@link SpringConfigSourceFactory} with the default prefix {@code armeria.xds.}. + * + *

To customize the bootstrap, set {@code armeria.xds.bootstrap} in your + * {@code application.yml}: + *

{@code
+ * armeria:
+ *   xds:
+ *     bootstrap: |
+ *       dynamic_resources:
+ *         cds_config:
+ *           custom_config_source:
+ *             name: armeria.config_source.spring
+ *             typed_config:
+ *               "@type": type.googleapis.com/armeria.xds.spring.SpringConfigSource
+ *               prefix: "my.custom.prefix."
+ * }
+ */ +@UnstableApi +@AutoConfiguration +@ConditionalOnClass(XdsBootstrap.class) +@PropertySource(value = "classpath:META-INF/armeria/xds/default-bootstrap.yml", + factory = YamlPropertySourceFactory.class) +public class SpringXdsAutoConfiguration { + + /** + * The property key for the xDS bootstrap YAML. + */ + static final String BOOTSTRAP_PROPERTY = "armeria.xds.bootstrap"; + + @Bean + @ConditionalOnMissingBean + SpringConfigSourceFactory springConfigSourceFactory(Environment environment) { + return SpringConfigSourceFactory.of(environment); + } + + @Bean + @ConditionalOnMissingBean + XdsBootstrap xdsBootstrap(Environment environment, List extensionFactories) { + final String bootstrapYaml = environment.getRequiredProperty(BOOTSTRAP_PROPERTY); + final Bootstrap bootstrap = XdsResourceReader.from(bootstrapYaml, Bootstrap.class); + return XdsBootstrap.builder(bootstrap) + .extensionFactories(extensionFactories) + .build(); + } + + @Bean + ApplicationListener springXdsRefreshListener(SpringConfigSourceFactory factory) { + return event -> factory.refresh(); + } +} diff --git a/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringXdsTypeRegistryPackageProvider.java b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringXdsTypeRegistryPackageProvider.java new file mode 100644 index 00000000000..c2d0c09d561 --- /dev/null +++ b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringXdsTypeRegistryPackageProvider.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.spring.xds; + +import java.util.List; + +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.xds.XdsTypeRegistryPackageProvider; + +/** + * Registers the {@link SpringConfigSource} protobuf type for xDS type registry discovery. + */ +@UnstableApi +public final class SpringXdsTypeRegistryPackageProvider implements XdsTypeRegistryPackageProvider { + + @Override + public Iterable packages() { + return List.of("com.linecorp.armeria.spring.xds"); + } +} diff --git a/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/YamlPropertySourceFactory.java b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/YamlPropertySourceFactory.java new file mode 100644 index 00000000000..b7b15498d4a --- /dev/null +++ b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/YamlPropertySourceFactory.java @@ -0,0 +1,42 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.spring.xds; + +import static java.util.Objects.requireNonNull; + +import java.util.Properties; + +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.support.EncodedResource; +import org.springframework.core.io.support.PropertySourceFactory; + +import com.linecorp.armeria.common.annotation.Nullable; + +final class YamlPropertySourceFactory implements PropertySourceFactory { + + @Override + public PropertySource createPropertySource(@Nullable String name, EncodedResource resource) { + final YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean(); + factory.setResources(resource.getResource()); + final Properties properties = requireNonNull(factory.getObject(), "properties"); + final String filename = requireNonNull(resource.getResource().getFilename(), "filename"); + final String sourceName = name != null ? name : filename; + return new PropertiesPropertySource(sourceName, properties); + } +} diff --git a/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/package-info.java b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/package-info.java new file mode 100644 index 00000000000..175010a79b4 --- /dev/null +++ b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * Spring x xDS integration. + */ +@NonNullByDefault +package com.linecorp.armeria.spring.xds; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; diff --git a/spring/boot4-xds/src/main/resources/META-INF/armeria/xds/default-bootstrap.yml b/spring/boot4-xds/src/main/resources/META-INF/armeria/xds/default-bootstrap.yml new file mode 100644 index 00000000000..263a5a5fa2e --- /dev/null +++ b/spring/boot4-xds/src/main/resources/META-INF/armeria/xds/default-bootstrap.yml @@ -0,0 +1,16 @@ +armeria: + xds: + bootstrap: | + dynamic_resources: + lds_config: + custom_config_source: + name: armeria.config_source.spring + typed_config: + "@type": type.googleapis.com/armeria.xds.spring.SpringConfigSource + prefix: "armeria.xds." + cds_config: + custom_config_source: + name: armeria.config_source.spring + typed_config: + "@type": type.googleapis.com/armeria.xds.spring.SpringConfigSource + prefix: "armeria.xds." diff --git a/spring/boot4-xds/src/main/resources/META-INF/services/com.linecorp.armeria.xds.XdsTypeRegistryPackageProvider b/spring/boot4-xds/src/main/resources/META-INF/services/com.linecorp.armeria.xds.XdsTypeRegistryPackageProvider new file mode 100644 index 00000000000..843a521b7df --- /dev/null +++ b/spring/boot4-xds/src/main/resources/META-INF/services/com.linecorp.armeria.xds.XdsTypeRegistryPackageProvider @@ -0,0 +1,17 @@ +# +# Copyright 2026 LY Corporation +# +# LY Corporation licenses this file to you under the Apache License, +# version 2.0 (the "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +com.linecorp.armeria.spring.xds.SpringXdsTypeRegistryPackageProvider diff --git a/spring/boot4-xds/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring/boot4-xds/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..d4c3a45551d --- /dev/null +++ b/spring/boot4-xds/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,17 @@ +# +# Copyright 2026 LY Corporation +# +# LY Corporation licenses this file to you under the Apache License, +# version 2.0 (the "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +com.linecorp.armeria.spring.xds.SpringXdsAutoConfiguration diff --git a/spring/boot4-xds/src/test/java/com/linecorp/armeria/spring/xds/SpringXdsCustomBootstrapTest.java b/spring/boot4-xds/src/test/java/com/linecorp/armeria/spring/xds/SpringXdsCustomBootstrapTest.java new file mode 100644 index 00000000000..0652fb22378 --- /dev/null +++ b/spring/boot4-xds/src/test/java/com/linecorp/armeria/spring/xds/SpringXdsCustomBootstrapTest.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.spring.xds; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import com.linecorp.armeria.xds.ClusterSnapshot; +import com.linecorp.armeria.xds.XdsBootstrap; + +@SpringBootTest(classes = SpringXdsCustomBootstrapTest.TestApp.class) +@ActiveProfiles("xds-custom-bootstrap-test") +class SpringXdsCustomBootstrapTest { + + @SpringBootApplication + static class TestApp { + } + + @Autowired + XdsBootstrap xdsBootstrap; + + @Test + void loadClusterWithCustomPrefix() { + final AtomicReference snapshotRef = new AtomicReference<>(); + xdsBootstrap.clusterRoot("custom-cluster") + .addSnapshotWatcher((snapshot, error) -> { + if (snapshot != null) { + snapshotRef.set(snapshot); + } + }); + + await().untilAsserted(() -> { + assertThat(snapshotRef.get()).isNotNull(); + assertThat(snapshotRef.get().xdsResource().resource().getName()) + .isEqualTo("custom-cluster"); + assertThat(snapshotRef.get().xdsResource().resource() + .getLoadAssignment() + .getEndpoints(0) + .getLbEndpoints(0) + .getEndpoint() + .getAddress() + .getSocketAddress() + .getPortValue()) + .isEqualTo(9090); + }); + } +} diff --git a/spring/boot4-xds/src/test/java/com/linecorp/armeria/spring/xds/SpringXdsYamlFileTest.java b/spring/boot4-xds/src/test/java/com/linecorp/armeria/spring/xds/SpringXdsYamlFileTest.java new file mode 100644 index 00000000000..8307faa8e07 --- /dev/null +++ b/spring/boot4-xds/src/test/java/com/linecorp/armeria/spring/xds/SpringXdsYamlFileTest.java @@ -0,0 +1,78 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.spring.xds; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import com.linecorp.armeria.xds.ClusterSnapshot; +import com.linecorp.armeria.xds.ListenerSnapshot; +import com.linecorp.armeria.xds.XdsBootstrap; + +@SpringBootTest(classes = SpringXdsYamlFileTest.TestApp.class) +@ActiveProfiles("xds-file-test") +class SpringXdsYamlFileTest { + + @SpringBootApplication + static class TestApp { + } + + @Autowired + XdsBootstrap xdsBootstrap; + + @Test + void loadClusterFromYamlFile() { + final AtomicReference snapshotRef = new AtomicReference<>(); + xdsBootstrap.clusterRoot("test-cluster") + .addSnapshotWatcher((snapshot, error) -> { + if (snapshot != null) { + snapshotRef.set(snapshot); + } + }); + + await().untilAsserted(() -> { + assertThat(snapshotRef.get()).isNotNull(); + assertThat(snapshotRef.get().xdsResource().resource().getName()) + .isEqualTo("test-cluster"); + }); + } + + @Test + void loadListenerFromYamlFile() { + final AtomicReference snapshotRef = new AtomicReference<>(); + xdsBootstrap.listenerRoot("test-listener") + .addSnapshotWatcher((snapshot, error) -> { + if (snapshot != null) { + snapshotRef.set(snapshot); + } + }); + + await().untilAsserted(() -> { + assertThat(snapshotRef.get()).isNotNull(); + assertThat(snapshotRef.get().xdsResource().resource().getName()) + .isEqualTo("test-listener"); + }); + } +} diff --git a/spring/boot4-xds/src/test/resources/application-xds-custom-bootstrap-test.yml b/spring/boot4-xds/src/test/resources/application-xds-custom-bootstrap-test.yml new file mode 100644 index 00000000000..d20f739600e --- /dev/null +++ b/spring/boot4-xds/src/test/resources/application-xds-custom-bootstrap-test.yml @@ -0,0 +1,25 @@ +armeria: + xds: + bootstrap: | + dynamic_resources: + cds_config: + custom_config_source: + name: armeria.config_source.spring + typed_config: + "@type": type.googleapis.com/armeria.xds.spring.SpringConfigSource + prefix: "custom.prefix." + +custom: + prefix: + custom-cluster: | + name: custom-cluster + type: STATIC + load_assignment: + cluster_name: custom-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: 9090 diff --git a/spring/boot4-xds/src/test/resources/application-xds-file-test.yml b/spring/boot4-xds/src/test/resources/application-xds-file-test.yml new file mode 100644 index 00000000000..a1ba25b45be --- /dev/null +++ b/spring/boot4-xds/src/test/resources/application-xds-file-test.yml @@ -0,0 +1,35 @@ +armeria: + xds: + test-listener: | + name: test-listener + api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + codec_type: AUTO + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: / + route: + cluster: test-cluster + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + test-cluster: | + name: test-cluster + type: STATIC + load_assignment: + cluster_name: test-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: 8080 diff --git a/xds-api/src/main/proto/armeria/xds/spring/spring_config_source.proto b/xds-api/src/main/proto/armeria/xds/spring/spring_config_source.proto new file mode 100644 index 00000000000..3bc87c092a4 --- /dev/null +++ b/xds-api/src/main/proto/armeria/xds/spring/spring_config_source.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package armeria.xds.spring; + +option java_package = "com.linecorp.armeria.spring.xds"; +option java_multiple_files = true; + +import "validate/validate.proto"; +import "armeria/xds/supported.proto"; + +// Configuration for the Spring Environment-backed xDS config source. +// +// This message is packed into the ``typed_config`` field of a +// ``custom_config_source`` in the bootstrap configuration. +message SpringConfigSource { + option (armeria.xds.supported.field) = 1; + // The prefix prepended to resource names to form Spring property keys. + // For example, with prefix ``armeria.xds.`` and resource name ``my-cluster``, + // the property key becomes ``armeria.xds.my-cluster``. + string prefix = 1 [(validate.rules).string = {min_len: 1}]; +} diff --git a/xds/src/main/java/com/linecorp/armeria/xds/XdsType.java b/xds/src/main/java/com/linecorp/armeria/xds/XdsType.java index 0d9c8dee845..f7f4b526f28 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/XdsType.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/XdsType.java @@ -19,28 +19,40 @@ import java.util.EnumSet; import java.util.Set; +import com.google.protobuf.GeneratedMessageV3; + import com.linecorp.armeria.common.annotation.UnstableApi; +import io.envoyproxy.envoy.config.cluster.v3.Cluster; +import io.envoyproxy.envoy.config.endpoint.v3.ClusterLoadAssignment; +import io.envoyproxy.envoy.config.listener.v3.Listener; +import io.envoyproxy.envoy.config.route.v3.RouteConfiguration; +import io.envoyproxy.envoy.config.route.v3.VirtualHost; +import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.Secret; + /** * A representation of the supported xDS types. */ @UnstableApi public enum XdsType { - LISTENER("type.googleapis.com/envoy.config.listener.v3.Listener"), - ROUTE("type.googleapis.com/envoy.config.route.v3.RouteConfiguration"), - CLUSTER("type.googleapis.com/envoy.config.cluster.v3.Cluster"), - ENDPOINT("type.googleapis.com/envoy.config.endpoint.v3.ClusterLoadAssignment"), - VIRTUAL_HOST("type.googleapis.com/envoy.config.route.v3.VirtualHost"), - SECRET("type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.Secret"); + LISTENER("type.googleapis.com/envoy.config.listener.v3.Listener", Listener.class), + ROUTE("type.googleapis.com/envoy.config.route.v3.RouteConfiguration", RouteConfiguration.class), + CLUSTER("type.googleapis.com/envoy.config.cluster.v3.Cluster", Cluster.class), + ENDPOINT("type.googleapis.com/envoy.config.endpoint.v3.ClusterLoadAssignment", + ClusterLoadAssignment.class), + VIRTUAL_HOST("type.googleapis.com/envoy.config.route.v3.VirtualHost", VirtualHost.class), + SECRET("type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.Secret", Secret.class); private static final Set discoverableTypes = EnumSet.of(LISTENER, ROUTE, CLUSTER, ENDPOINT, SECRET); private final String typeUrl; + private final Class resourceClass; - XdsType(String typeUrl) { + XdsType(String typeUrl, Class resourceClass) { this.typeUrl = typeUrl; + this.resourceClass = resourceClass; } /** @@ -50,6 +62,13 @@ public String typeUrl() { return typeUrl; } + /** + * Returns the protobuf class for this xDS type. + */ + public Class resourceClass() { + return resourceClass; + } + static Set discoverableTypes() { return discoverableTypes; } From 24fb597440382548a44a4b1cf04b5bdc6a49bd21 Mon Sep 17 00:00:00 2001 From: jrhee17 Date: Fri, 7 Aug 2026 18:13:03 +0900 Subject: [PATCH 2/2] address comments by @minwoox --- .../resources/config-repo/application.yml | 66 ++++++++++--------- .../SpringCloudConfigXdsExampleTest.java | 4 +- .../spring/xds/SpringConfigSourceFactory.java | 24 ++++--- .../xds/SpringXdsAutoConfiguration.java | 44 ++++++++----- .../SpringXdsTypeRegistryPackageProvider.java | 5 +- ...ava => YamlFilePropertySourceFactory.java} | 8 ++- .../armeria/xds/default-bootstrap.yml | 4 +- .../resources/application-xds-file-test.yml | 66 ++++++++++--------- .../xds/spring/spring_config_source.proto | 2 +- 9 files changed, 124 insertions(+), 99 deletions(-) rename spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/{YamlPropertySourceFactory.java => YamlFilePropertySourceFactory.java} (82%) diff --git a/examples/spring-cloud-config-xds/src/main/resources/config-repo/application.yml b/examples/spring-cloud-config-xds/src/main/resources/config-repo/application.yml index aaaf3fd82fa..15d1f513336 100644 --- a/examples/spring-cloud-config-xds/src/main/resources/config-repo/application.yml +++ b/examples/spring-cloud-config-xds/src/main/resources/config-repo/application.yml @@ -1,35 +1,37 @@ armeria: xds: - test-listener: | - name: test-listener - api_listener: + listener: + test-listener: | + name: test-listener api_listener: - "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager - codec_type: AUTO - stat_prefix: ingress_http - route_config: - name: local_route - virtual_hosts: - - name: local_service - domains: ["*"] - routes: - - match: - prefix: / - route: - cluster: test-cluster - http_filters: - - name: envoy.filters.http.router - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router - test-cluster: | - name: test-cluster - type: STATIC - load_assignment: - cluster_name: test-cluster - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: 127.0.0.1 - port_value: 8888 + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + codec_type: AUTO + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: / + route: + cluster: test-cluster + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + cluster: + test-cluster: | + name: test-cluster + type: STATIC + load_assignment: + cluster_name: test-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: 8888 diff --git a/examples/spring-cloud-config-xds/src/test/java/example/springframework/boot/xds/cloudconfig/SpringCloudConfigXdsExampleTest.java b/examples/spring-cloud-config-xds/src/test/java/example/springframework/boot/xds/cloudconfig/SpringCloudConfigXdsExampleTest.java index 849f0f24a67..d9debcc06b5 100644 --- a/examples/spring-cloud-config-xds/src/test/java/example/springframework/boot/xds/cloudconfig/SpringCloudConfigXdsExampleTest.java +++ b/examples/spring-cloud-config-xds/src/test/java/example/springframework/boot/xds/cloudconfig/SpringCloudConfigXdsExampleTest.java @@ -85,9 +85,9 @@ void webClientViaXdsPreprocessor() { // Override the cluster endpoint to point at the config server's actual port environment.getPropertySources() .addFirst(new MapPropertySource("test", - Map.of("armeria.xds.test-cluster", clusterYaml(configPort)))); + Map.of("armeria.xds.cluster.test-cluster", clusterYaml(configPort)))); applicationContext.publishEvent( - new EnvironmentChangeEvent(Set.of("armeria.xds.test-cluster"))); + new EnvironmentChangeEvent(Set.of("armeria.xds.cluster.test-cluster"))); // Wait for the xDS update to propagate await().untilAsserted(() -> diff --git a/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringConfigSourceFactory.java b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringConfigSourceFactory.java index 80a695cc642..d92af8f3445 100644 --- a/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringConfigSourceFactory.java +++ b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringConfigSourceFactory.java @@ -20,6 +20,8 @@ import java.util.EnumMap; import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; import org.springframework.core.env.Environment; @@ -34,7 +36,7 @@ import com.linecorp.armeria.xds.configsource.InterestedResources; import com.linecorp.armeria.xds.configsource.SotwConfigSourceSubscriptionFactory; import com.linecorp.armeria.xds.filter.FactoryContext; -import com.linecorp.armeria.xds.stream.RefCountedStream; +import com.linecorp.armeria.xds.spring.SpringConfigSource; import com.linecorp.armeria.xds.stream.SnapshotStream; import com.linecorp.armeria.xds.stream.Subscription; @@ -49,7 +51,8 @@ * *

The property key prefix is configured via a {@link SpringConfigSource} * packed into the {@code typed_config} field of the bootstrap's - * {@code custom_config_source}. The default prefix is {@code armeria.xds.}. + * {@code custom_config_source}. The default prefixes are + * {@code armeria.xds.listener.} for LDS and {@code armeria.xds.cluster.} for CDS. * *

Call {@link #refresh()} to re-read properties and push updated resources * to subscribers (e.g. from a Spring Cloud Config {@code EnvironmentChangeEvent}). @@ -89,7 +92,7 @@ public SnapshotStream create(ConfigSource configSource, final SpringConfigSource springConfigSource = factoryContext.validator().unpack(configSource.getCustomConfigSource().getTypedConfig(), SpringConfigSource.class); - final String rawPrefix = springConfigSource.getPrefix(); + final String rawPrefix = springConfigSource.getPrefix().trim(); if (rawPrefix.isEmpty() || ".".equals(rawPrefix)) { throw new IllegalArgumentException( "SpringConfigSource 'prefix' must not be empty. " + @@ -151,16 +154,21 @@ private static DiscoveryResponse buildResponse(Environment environment, String p return builder.build(); } - static final class RefreshSignal extends RefCountedStream { + static final class RefreshSignal implements SnapshotStream { + + private final Set> watchers = new CopyOnWriteArraySet<>(); @Override - protected Subscription onStart(SnapshotWatcher watcher) { - emit(SIGNAL, null); - return Subscription.noop(); + public Subscription subscribe(SnapshotWatcher watcher) { + watchers.add(watcher); + watcher.onUpdate(SIGNAL, null); + return () -> watchers.remove(watcher); } void push() { - emit(SIGNAL, null); + for (SnapshotWatcher w : watchers) { + w.onUpdate(SIGNAL, null); + } } } } diff --git a/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringXdsAutoConfiguration.java b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringXdsAutoConfiguration.java index 96280e82867..21b599f3f5c 100644 --- a/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringXdsAutoConfiguration.java +++ b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringXdsAutoConfiguration.java @@ -46,34 +46,44 @@ *

xDS resources

* *

Each xDS resource is stored as a separate property with the key - * {@code armeria.xds.} containing the resource YAML + * {@code armeria.xds..} containing the resource YAML * (without {@code @type} wrappers — the type is inferred from the - * subscription context). + * subscription context). The default prefixes are: + *

    + *
  • {@code armeria.xds.listener.} — for Listener resources (LDS)
  • + *
  • {@code armeria.xds.cluster.} — for Cluster resources (CDS)
  • + *
* *

Example: *

{@code
  * armeria:
  *   xds:
- *     my-cluster: |
- *       name: my-cluster
- *       type: STATIC
- *       load_assignment:
- *         cluster_name: my-cluster
- *         endpoints:
- *           - lb_endpoints:
- *               - endpoint:
- *                   address:
- *                     socket_address:
- *                       address: 127.0.0.1
- *                       port_value: 8080
+ *     listener:
+ *       my-listener: |
+ *         name: my-listener
+ *         api_listener: ...
+ *     cluster:
+ *       my-cluster: |
+ *         name: my-cluster
+ *         type: STATIC
+ *         load_assignment:
+ *           cluster_name: my-cluster
+ *           endpoints:
+ *             - lb_endpoints:
+ *                 - endpoint:
+ *                     address:
+ *                       socket_address:
+ *                         address: 127.0.0.1
+ *                         port_value: 8080
  * }
* *

Bootstrap

* *

The xDS {@link Bootstrap} is created from the {@code armeria.xds.bootstrap} property. * If not set, a default bootstrap is loaded from - * {@code META-INF/armeria/xds/default-bootstrap.yml}, which configures both LDS and CDS - * to use {@link SpringConfigSourceFactory} with the default prefix {@code armeria.xds.}. + * {@code META-INF/armeria/xds/default-bootstrap.yml}, which configures LDS with + * prefix {@code armeria.xds.listener.} and CDS with prefix {@code armeria.xds.cluster.} + * via {@link SpringConfigSourceFactory}. * *

To customize the bootstrap, set {@code armeria.xds.bootstrap} in your * {@code application.yml}: @@ -94,7 +104,7 @@ @AutoConfiguration @ConditionalOnClass(XdsBootstrap.class) @PropertySource(value = "classpath:META-INF/armeria/xds/default-bootstrap.yml", - factory = YamlPropertySourceFactory.class) + factory = YamlFilePropertySourceFactory.class) public class SpringXdsAutoConfiguration { /** diff --git a/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringXdsTypeRegistryPackageProvider.java b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringXdsTypeRegistryPackageProvider.java index c2d0c09d561..e14ee37ccd4 100644 --- a/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringXdsTypeRegistryPackageProvider.java +++ b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringXdsTypeRegistryPackageProvider.java @@ -16,10 +16,11 @@ package com.linecorp.armeria.spring.xds; -import java.util.List; +import com.google.common.collect.ImmutableList; import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.xds.XdsTypeRegistryPackageProvider; +import com.linecorp.armeria.xds.spring.SpringConfigSource; /** * Registers the {@link SpringConfigSource} protobuf type for xDS type registry discovery. @@ -29,6 +30,6 @@ public final class SpringXdsTypeRegistryPackageProvider implements XdsTypeRegist @Override public Iterable packages() { - return List.of("com.linecorp.armeria.spring.xds"); + return ImmutableList.of("com.linecorp.armeria.xds.spring"); } } diff --git a/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/YamlPropertySourceFactory.java b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/YamlFilePropertySourceFactory.java similarity index 82% rename from spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/YamlPropertySourceFactory.java rename to spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/YamlFilePropertySourceFactory.java index b7b15498d4a..027ff4f4fdc 100644 --- a/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/YamlPropertySourceFactory.java +++ b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/YamlFilePropertySourceFactory.java @@ -18,6 +18,7 @@ import static java.util.Objects.requireNonNull; +import java.util.Objects; import java.util.Properties; import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; @@ -28,15 +29,16 @@ import com.linecorp.armeria.common.annotation.Nullable; -final class YamlPropertySourceFactory implements PropertySourceFactory { +final class YamlFilePropertySourceFactory implements PropertySourceFactory { @Override public PropertySource createPropertySource(@Nullable String name, EncodedResource resource) { final YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean(); factory.setResources(resource.getResource()); final Properties properties = requireNonNull(factory.getObject(), "properties"); - final String filename = requireNonNull(resource.getResource().getFilename(), "filename"); - final String sourceName = name != null ? name : filename; + final String sourceName = Objects.requireNonNullElseGet( + name, () -> requireNonNull(resource.getResource().getFilename(), + "resource must be file-backed")); return new PropertiesPropertySource(sourceName, properties); } } diff --git a/spring/boot4-xds/src/main/resources/META-INF/armeria/xds/default-bootstrap.yml b/spring/boot4-xds/src/main/resources/META-INF/armeria/xds/default-bootstrap.yml index 263a5a5fa2e..f5612a0e750 100644 --- a/spring/boot4-xds/src/main/resources/META-INF/armeria/xds/default-bootstrap.yml +++ b/spring/boot4-xds/src/main/resources/META-INF/armeria/xds/default-bootstrap.yml @@ -7,10 +7,10 @@ armeria: name: armeria.config_source.spring typed_config: "@type": type.googleapis.com/armeria.xds.spring.SpringConfigSource - prefix: "armeria.xds." + prefix: "armeria.xds.listener." cds_config: custom_config_source: name: armeria.config_source.spring typed_config: "@type": type.googleapis.com/armeria.xds.spring.SpringConfigSource - prefix: "armeria.xds." + prefix: "armeria.xds.cluster." diff --git a/spring/boot4-xds/src/test/resources/application-xds-file-test.yml b/spring/boot4-xds/src/test/resources/application-xds-file-test.yml index a1ba25b45be..09b6fe0893f 100644 --- a/spring/boot4-xds/src/test/resources/application-xds-file-test.yml +++ b/spring/boot4-xds/src/test/resources/application-xds-file-test.yml @@ -1,35 +1,37 @@ armeria: xds: - test-listener: | - name: test-listener - api_listener: + listener: + test-listener: | + name: test-listener api_listener: - "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager - codec_type: AUTO - stat_prefix: ingress_http - route_config: - name: local_route - virtual_hosts: - - name: local_service - domains: ["*"] - routes: - - match: - prefix: / - route: - cluster: test-cluster - http_filters: - - name: envoy.filters.http.router - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router - test-cluster: | - name: test-cluster - type: STATIC - load_assignment: - cluster_name: test-cluster - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: 127.0.0.1 - port_value: 8080 + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + codec_type: AUTO + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: / + route: + cluster: test-cluster + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + cluster: + test-cluster: | + name: test-cluster + type: STATIC + load_assignment: + cluster_name: test-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: 8080 diff --git a/xds-api/src/main/proto/armeria/xds/spring/spring_config_source.proto b/xds-api/src/main/proto/armeria/xds/spring/spring_config_source.proto index 3bc87c092a4..fe1e01ec276 100644 --- a/xds-api/src/main/proto/armeria/xds/spring/spring_config_source.proto +++ b/xds-api/src/main/proto/armeria/xds/spring/spring_config_source.proto @@ -2,7 +2,7 @@ syntax = "proto3"; package armeria.xds.spring; -option java_package = "com.linecorp.armeria.spring.xds"; +option java_package = "com.linecorp.armeria.xds.spring"; option java_multiple_files = true; import "validate/validate.proto";