Skip to content

Commit 7149a6a

Browse files
committed
minimal impl
1 parent 6e99c57 commit 7149a6a

25 files changed

Lines changed: 1005 additions & 7 deletions

File tree

dependencies.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ spring7 = "7.0.8"
174174
spring-boot2 = "2.7.18"
175175
spring-boot3 = "3.5.16"
176176
spring-boot4 = "4.1.0"
177+
spring-cloud-config-server = "5.0.4"
178+
spring-cloud-context = "5.0.2"
177179
testcontainers = "2.0.5"
178180
thrift09 = { strictly = "0.9.3-1" }
179181
thrift012 = { strictly = "0.12.0" }
@@ -1422,6 +1424,14 @@ version.ref = "spring-boot4"
14221424
module = "org.springframework.boot:spring-boot-tomcat"
14231425
version.ref = "spring-boot4"
14241426

1427+
[libraries.spring-cloud-config-server]
1428+
module = "org.springframework.cloud:spring-cloud-config-server"
1429+
version.ref = "spring-cloud-config-server"
1430+
1431+
[libraries.spring-cloud-context]
1432+
module = "org.springframework.cloud:spring-cloud-context"
1433+
version.ref = "spring-cloud-context"
1434+
14251435
[libraries.testcontainers-core]
14261436
module = "org.testcontainers:testcontainers"
14271437
[libraries.testcontainers-consul]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
dependencies {
2+
implementation project(':spring:boot4-xds')
3+
implementation libs.spring.cloud.config.server
4+
testImplementation libs.spring.boot4.starter.test
5+
}
6+
7+
tasks.register('runConfigServer', JavaExec) {
8+
classpath = sourceSets.main.runtimeClasspath
9+
mainClass = 'example.springframework.boot.xds.cloudconfig.server.ConfigServerMain'
10+
}
11+
12+
tasks.register('runClient', JavaExec) {
13+
classpath = sourceSets.main.runtimeClasspath
14+
mainClass = 'example.springframework.boot.xds.cloudconfig.client.ClientMain'
15+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package example.springframework.boot.xds.cloudconfig.client;
2+
3+
import java.util.Map;
4+
5+
import org.springframework.boot.SpringApplication;
6+
import org.springframework.boot.autoconfigure.SpringBootApplication;
7+
import org.springframework.web.bind.annotation.GetMapping;
8+
import org.springframework.web.bind.annotation.RequestParam;
9+
import org.springframework.web.bind.annotation.RestController;
10+
11+
import com.linecorp.armeria.client.WebClient;
12+
import com.linecorp.armeria.common.AggregatedHttpResponse;
13+
14+
/**
15+
* A Spring Boot client application that loads xDS resources from a
16+
* Spring Cloud Config Server. Properties are loaded from {@code application-client.yml}.
17+
*
18+
* <p>Exposes a {@code GET /relay?path=...} endpoint that forwards the request to
19+
* the upstream resolved by the xDS listener and returns the response.
20+
*/
21+
@SpringBootApplication
22+
@RestController
23+
public class ClientMain {
24+
25+
private final WebClient xdsWebClient;
26+
27+
public ClientMain(WebClient xdsWebClient) {
28+
this.xdsWebClient = xdsWebClient;
29+
}
30+
31+
public static void main(String[] args) {
32+
createApplication().run(args);
33+
}
34+
35+
public static SpringApplication createApplication() {
36+
final SpringApplication app = new SpringApplication(ClientMain.class);
37+
app.setAdditionalProfiles("client");
38+
// spring-cloud-config-server on the classpath disables the config client
39+
// via ConfigServerBootstrapApplicationListener; re-enable it explicitly.
40+
app.setDefaultProperties(Map.of("spring.cloud.config.enabled", "true"));
41+
return app;
42+
}
43+
44+
@GetMapping("/relay")
45+
String relay(@RequestParam(defaultValue = "/actuator/health") String path) {
46+
final AggregatedHttpResponse response = xdsWebClient.get(path).aggregate().join();
47+
return response.status() + "\n" + response.contentUtf8();
48+
}
49+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package example.springframework.boot.xds.cloudconfig.client;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
6+
import com.linecorp.armeria.client.WebClient;
7+
import com.linecorp.armeria.xds.XdsBootstrap;
8+
import com.linecorp.armeria.xds.client.endpoint.XdsHttpPreprocessor;
9+
10+
@Configuration
11+
class XdsClientConfig {
12+
13+
@Bean
14+
XdsHttpPreprocessor xdsHttpPreprocessor(XdsBootstrap xdsBootstrap) {
15+
return XdsHttpPreprocessor.ofListener("test-listener", xdsBootstrap);
16+
}
17+
18+
@Bean
19+
WebClient xdsWebClient(XdsHttpPreprocessor xdsHttpPreprocessor) {
20+
return WebClient.of(xdsHttpPreprocessor);
21+
}
22+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package example.springframework.boot.xds.cloudconfig.server;
2+
3+
import org.springframework.boot.SpringApplication;
4+
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
import org.springframework.cloud.config.server.EnableConfigServer;
6+
7+
import com.linecorp.armeria.spring.xds.SpringXdsAutoConfiguration;
8+
9+
/**
10+
* A Spring Cloud Config Server that serves xDS cluster definitions from a native
11+
* (file-based) repository.
12+
*/
13+
@EnableConfigServer
14+
@SpringBootApplication(exclude = SpringXdsAutoConfiguration.class)
15+
public class ConfigServerMain {
16+
17+
public static void main(String[] args) {
18+
createApplication().run(args);
19+
}
20+
21+
public static SpringApplication createApplication() {
22+
final SpringApplication app = new SpringApplication(ConfigServerMain.class);
23+
app.setAdditionalProfiles("server", "native");
24+
return app;
25+
}
26+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
armeria:
2+
xds:
3+
test-listener: |
4+
name: test-listener
5+
api_listener:
6+
api_listener:
7+
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
8+
codec_type: AUTO
9+
stat_prefix: ingress_http
10+
route_config:
11+
name: local_route
12+
virtual_hosts:
13+
- name: local_service
14+
domains: ["*"]
15+
routes:
16+
- match:
17+
prefix: /
18+
route:
19+
cluster: test-cluster
20+
http_filters:
21+
- name: envoy.filters.http.router
22+
typed_config:
23+
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
24+
test-cluster: |
25+
name: test-cluster
26+
type: STATIC
27+
load_assignment:
28+
cluster_name: test-cluster
29+
endpoints:
30+
- lb_endpoints:
31+
- endpoint:
32+
address:
33+
socket_address:
34+
address: 127.0.0.1
35+
port_value: 8888
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
spring:
2+
config:
3+
"import": "configserver:"
4+
cloud:
5+
config:
6+
uri: http://localhost:8888
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
server:
2+
port: 8888
3+
4+
spring:
5+
cloud:
6+
config:
7+
server:
8+
native:
9+
searchLocations: classpath:/config-repo/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package example.springframework.boot.xds.cloudconfig;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.awaitility.Awaitility.await;
5+
6+
import java.util.Map;
7+
import java.util.Set;
8+
import java.util.concurrent.atomic.AtomicReference;
9+
10+
import org.junit.jupiter.api.AfterAll;
11+
import org.junit.jupiter.api.BeforeAll;
12+
import org.junit.jupiter.api.Test;
13+
import org.springframework.beans.factory.annotation.Autowired;
14+
import org.springframework.boot.test.context.SpringBootTest;
15+
import org.springframework.boot.web.server.context.WebServerApplicationContext;
16+
import org.springframework.cloud.context.environment.EnvironmentChangeEvent;
17+
import org.springframework.context.ApplicationContext;
18+
import org.springframework.context.ConfigurableApplicationContext;
19+
import org.springframework.core.env.ConfigurableEnvironment;
20+
import org.springframework.core.env.MapPropertySource;
21+
22+
import com.linecorp.armeria.client.WebClient;
23+
import com.linecorp.armeria.common.AggregatedHttpResponse;
24+
import com.linecorp.armeria.common.HttpStatus;
25+
import com.linecorp.armeria.xds.ClusterSnapshot;
26+
import com.linecorp.armeria.xds.XdsBootstrap;
27+
28+
import example.springframework.boot.xds.cloudconfig.client.ClientMain;
29+
import example.springframework.boot.xds.cloudconfig.server.ConfigServerMain;
30+
31+
@SpringBootTest(
32+
classes = ClientMain.class,
33+
properties = {
34+
"spring.config.import=configserver:",
35+
"spring.cloud.config.enabled=true"
36+
})
37+
class SpringCloudConfigXdsExampleTest {
38+
39+
private static ConfigurableApplicationContext server;
40+
private static int configPort;
41+
42+
@BeforeAll
43+
static void startConfigServer() {
44+
server = ConfigServerMain.createApplication().run("--server.port=0");
45+
46+
configPort = ((WebServerApplicationContext) server).getWebServer().getPort();
47+
System.setProperty("spring.cloud.config.uri", "http://localhost:" + configPort);
48+
}
49+
50+
@AfterAll
51+
static void stopConfigServer() {
52+
System.clearProperty("spring.cloud.config.uri");
53+
if (server != null) {
54+
server.close();
55+
}
56+
}
57+
58+
@Autowired
59+
WebClient xdsWebClient;
60+
61+
@Autowired
62+
XdsBootstrap xdsBootstrap;
63+
64+
@Autowired
65+
ConfigurableEnvironment environment;
66+
67+
@Autowired
68+
ApplicationContext applicationContext;
69+
70+
@Test
71+
void webClientViaXdsPreprocessor() {
72+
// Verify the initial cluster snapshot loaded from config server (port 8888)
73+
final AtomicReference<ClusterSnapshot> snapshotRef = new AtomicReference<>();
74+
xdsBootstrap.clusterRoot("test-cluster")
75+
.addSnapshotWatcher((snapshot, error) -> {
76+
if (snapshot != null) {
77+
snapshotRef.set(snapshot);
78+
}
79+
});
80+
await().untilAsserted(() -> {
81+
assertThat(snapshotRef.get()).isNotNull();
82+
assertThat(endpointPort(snapshotRef.get())).isEqualTo(8888);
83+
});
84+
85+
// Override the cluster endpoint to point at the config server's actual port
86+
environment.getPropertySources()
87+
.addFirst(new MapPropertySource("test",
88+
Map.of("armeria.xds.test-cluster", clusterYaml(configPort))));
89+
applicationContext.publishEvent(
90+
new EnvironmentChangeEvent(Set.of("armeria.xds.test-cluster")));
91+
92+
// Wait for the xDS update to propagate
93+
await().untilAsserted(() ->
94+
assertThat(endpointPort(snapshotRef.get())).isEqualTo(configPort));
95+
96+
// Call the config server's actuator health endpoint via the xDS-resolved client
97+
final AggregatedHttpResponse response =
98+
xdsWebClient.get("/actuator/health").aggregate().join();
99+
assertThat(response.status()).isEqualTo(HttpStatus.OK);
100+
}
101+
102+
private static int endpointPort(ClusterSnapshot snapshot) {
103+
return snapshot.xdsResource().resource()
104+
.getLoadAssignment()
105+
.getEndpoints(0)
106+
.getLbEndpoints(0)
107+
.getEndpoint()
108+
.getAddress()
109+
.getSocketAddress()
110+
.getPortValue();
111+
}
112+
113+
private static String clusterYaml(int port) {
114+
return """
115+
name: test-cluster
116+
type: STATIC
117+
load_assignment:
118+
cluster_name: test-cluster
119+
endpoints:
120+
- lb_endpoints:
121+
- endpoint:
122+
address:
123+
socket_address:
124+
address: 127.0.0.1
125+
port_value: %d
126+
""".formatted(port);
127+
}
128+
}

settings.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ includeWithFlags ':spring:boot4-autoconfigure', 'java17', 'publish', 'r
189189
includeWithFlags ':spring:boot4-starter', 'java17', 'publish', 'relocate', 'no_aggregation'
190190
includeWithFlags ':spring:boot4-webflux-autoconfigure', 'java17', 'publish', 'relocate'
191191
includeWithFlags ':spring:boot4-webflux-starter', 'java17', 'publish', 'relocate', 'no_aggregation'
192+
includeWithFlags ':spring:boot4-xds', 'java17', 'publish', 'relocate'
192193
includeWithFlags ':spring:spring7', 'java17', 'publish', 'relocate'
193194

194195
includeWithFlags ':dropwizard2', 'java', 'publish', 'relocate'
@@ -332,6 +333,7 @@ includeWithFlags ':examples:spring-boot-minimal', 'java17'
332333
includeWithFlags ':examples:spring-boot-minimal-kotlin', 'java17', 'kotlin'
333334
includeWithFlags ':examples:spring-boot-tomcat', 'java17'
334335
includeWithFlags ':examples:spring-boot-webflux', 'java17'
336+
includeWithFlags ':examples:spring-cloud-config-xds', 'java17'
335337
includeWithFlags ':examples:static-files', 'java11'
336338
includeWithFlags ':examples:thrift', 'java11'
337339
includeWithFlags ':examples:tutorials:grpc-tutorial', 'java11'

0 commit comments

Comments
 (0)