Skip to content

Commit 789d30d

Browse files
committed
Add SSL service connection support for ElasticSearch
See gh-41137
1 parent 7cf9cc7 commit 789d30d

File tree

13 files changed

+583
-11
lines changed

13 files changed

+583
-11
lines changed

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchConnectionDetails.java

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2023 the original author or authors.
2+
* Copyright 2012-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -21,6 +21,7 @@
2121
import java.util.List;
2222

2323
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
24+
import org.springframework.boot.ssl.SslBundle;
2425

2526
/**
2627
* Details required to establish a connection to an Elasticsearch service.
@@ -63,6 +64,15 @@ default String getPathPrefix() {
6364
return null;
6465
}
6566

67+
/**
68+
* SSL bundle to use.
69+
* @return the SSL bundle to use
70+
* @since 3.5.0
71+
*/
72+
default SslBundle getSslBundle() {
73+
return null;
74+
}
75+
6676
/**
6777
* An Elasticsearch node.
6878
*

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchRestClientConfigurations.java

+23-8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2023 the original author or authors.
2+
* Copyright 2012-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -44,12 +44,14 @@
4444
import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate;
4545
import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchConnectionDetails.Node;
4646
import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchConnectionDetails.Node.Protocol;
47+
import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchProperties.Restclient.Ssl;
4748
import org.springframework.boot.context.properties.PropertyMapper;
4849
import org.springframework.boot.ssl.SslBundle;
4950
import org.springframework.boot.ssl.SslBundles;
5051
import org.springframework.boot.ssl.SslOptions;
5152
import org.springframework.context.annotation.Bean;
5253
import org.springframework.context.annotation.Configuration;
54+
import org.springframework.util.Assert;
5355
import org.springframework.util.StringUtils;
5456

5557
/**
@@ -75,8 +77,8 @@ static class RestClientBuilderConfiguration {
7577

7678
@Bean
7779
@ConditionalOnMissingBean(ElasticsearchConnectionDetails.class)
78-
PropertiesElasticsearchConnectionDetails elasticsearchConnectionDetails() {
79-
return new PropertiesElasticsearchConnectionDetails(this.properties);
80+
PropertiesElasticsearchConnectionDetails elasticsearchConnectionDetails(ObjectProvider<SslBundles> sslBundles) {
81+
return new PropertiesElasticsearchConnectionDetails(this.properties, sslBundles.getIfAvailable());
8082
}
8183

8284
@Bean
@@ -87,16 +89,16 @@ RestClientBuilderCustomizer defaultRestClientBuilderCustomizer(
8789

8890
@Bean
8991
RestClientBuilder elasticsearchRestClientBuilder(ElasticsearchConnectionDetails connectionDetails,
90-
ObjectProvider<RestClientBuilderCustomizer> builderCustomizers, ObjectProvider<SslBundles> sslBundles) {
92+
ObjectProvider<RestClientBuilderCustomizer> builderCustomizers) {
9193
RestClientBuilder builder = RestClient.builder(connectionDetails.getNodes()
9294
.stream()
9395
.map((node) -> new HttpHost(node.hostname(), node.port(), node.protocol().getScheme()))
9496
.toArray(HttpHost[]::new));
9597
builder.setHttpClientConfigCallback((httpClientBuilder) -> {
9698
builderCustomizers.orderedStream().forEach((customizer) -> customizer.customize(httpClientBuilder));
97-
String sslBundleName = this.properties.getRestclient().getSsl().getBundle();
98-
if (StringUtils.hasText(sslBundleName)) {
99-
configureSsl(httpClientBuilder, sslBundles.getObject().getBundle(sslBundleName));
99+
SslBundle sslBundle = connectionDetails.getSslBundle();
100+
if (sslBundle != null) {
101+
configureSsl(httpClientBuilder, sslBundle);
100102
}
101103
return httpClientBuilder;
102104
});
@@ -236,8 +238,11 @@ static class PropertiesElasticsearchConnectionDetails implements ElasticsearchCo
236238

237239
private final ElasticsearchProperties properties;
238240

239-
PropertiesElasticsearchConnectionDetails(ElasticsearchProperties properties) {
241+
private final SslBundles sslBundles;
242+
243+
PropertiesElasticsearchConnectionDetails(ElasticsearchProperties properties, SslBundles sslBundles) {
240244
this.properties = properties;
245+
this.sslBundles = sslBundles;
241246
}
242247

243248
@Override
@@ -260,6 +265,16 @@ public String getPathPrefix() {
260265
return this.properties.getPathPrefix();
261266
}
262267

268+
@Override
269+
public SslBundle getSslBundle() {
270+
Ssl ssl = this.properties.getRestclient().getSsl();
271+
if (StringUtils.hasLength(ssl.getBundle())) {
272+
Assert.notNull(this.sslBundles, "SSL bundle name has been set but no SSL bundles found in context");
273+
return this.sslBundles.getBundle(ssl.getBundle());
274+
}
275+
return null;
276+
}
277+
263278
private Node createNode(String uri) {
264279
if (!(uri.startsWith("http://") || uri.startsWith("https://"))) {
265280
uri = "http://" + uri;

spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/elasticsearch/ElasticsearchContainerConnectionDetailsFactory.java

+81-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2023 the original author or authors.
2+
* Copyright 2012-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,15 +16,26 @@
1616

1717
package org.springframework.boot.testcontainers.service.connection.elasticsearch;
1818

19+
import java.io.ByteArrayInputStream;
20+
import java.io.IOException;
21+
import java.security.KeyStore;
22+
import java.security.KeyStoreException;
23+
import java.security.NoSuchAlgorithmException;
24+
import java.security.cert.Certificate;
25+
import java.security.cert.CertificateException;
26+
import java.security.cert.CertificateFactory;
1927
import java.util.List;
2028

2129
import org.testcontainers.elasticsearch.ElasticsearchContainer;
2230

2331
import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchConnectionDetails;
2432
import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchConnectionDetails.Node.Protocol;
33+
import org.springframework.boot.ssl.SslBundle;
34+
import org.springframework.boot.ssl.SslStoreBundle;
2535
import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory;
2636
import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource;
2737
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
38+
import org.springframework.boot.testcontainers.service.connection.Ssl;
2839

2940
/**
3041
* {@link ContainerConnectionDetailsFactory} to create
@@ -53,15 +64,83 @@ protected ElasticsearchConnectionDetails getContainerConnectionDetails(
5364
private static final class ElasticsearchContainerConnectionDetails
5465
extends ContainerConnectionDetails<ElasticsearchContainer> implements ElasticsearchConnectionDetails {
5566

67+
private volatile SslBundle sslBundle;
68+
5669
private ElasticsearchContainerConnectionDetails(ContainerConnectionSource<ElasticsearchContainer> source) {
5770
super(source);
5871
}
5972

73+
@Override
74+
public String getUsername() {
75+
return "elastic";
76+
}
77+
78+
@Override
79+
public String getPassword() {
80+
return getContainer().getEnvMap().get("ELASTIC_PASSWORD");
81+
}
82+
6083
@Override
6184
public List<Node> getNodes() {
6285
String host = getContainer().getHost();
6386
Integer port = getContainer().getMappedPort(DEFAULT_PORT);
64-
return List.of(new Node(host, port, Protocol.HTTP, null, null));
87+
return List.of(new Node(host, port, (getSslBundle() != null) ? Protocol.HTTPS : Protocol.HTTP,
88+
getUsername(), getPassword()));
89+
}
90+
91+
@Override
92+
public SslBundle getSslBundle() {
93+
if (this.sslBundle != null) {
94+
return this.sslBundle;
95+
}
96+
SslBundle sslBundle = super.getSslBundle();
97+
if (sslBundle != null) {
98+
this.sslBundle = sslBundle;
99+
return sslBundle;
100+
}
101+
if (hasAnnotation(Ssl.class)) {
102+
byte[] caCertificate = getContainer().caCertAsBytes().orElse(null);
103+
if (caCertificate != null) {
104+
KeyStore trustStore = createTrustStore(caCertificate);
105+
sslBundle = createSslBundleWithTrustStore(trustStore);
106+
this.sslBundle = sslBundle;
107+
return sslBundle;
108+
}
109+
}
110+
return null;
111+
}
112+
113+
private SslBundle createSslBundleWithTrustStore(KeyStore trustStore) {
114+
return SslBundle.of(new SslStoreBundle() {
115+
@Override
116+
public KeyStore getKeyStore() {
117+
return null;
118+
}
119+
120+
@Override
121+
public String getKeyStorePassword() {
122+
return null;
123+
}
124+
125+
@Override
126+
public KeyStore getTrustStore() {
127+
return trustStore;
128+
}
129+
});
130+
}
131+
132+
private KeyStore createTrustStore(byte[] caCertificate) {
133+
try {
134+
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
135+
keyStore.load(null, null);
136+
CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
137+
Certificate certificate = certFactory.generateCertificate(new ByteArrayInputStream(caCertificate));
138+
keyStore.setCertificateEntry("ca", certificate);
139+
return keyStore;
140+
}
141+
catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException ex) {
142+
throw new IllegalStateException("Failed to create keystore from CA certificate", ex);
143+
}
65144
}
66145

67146
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
plugins {
2+
id "java"
3+
id "org.springframework.boot.conventions"
4+
id "org.springframework.boot.docker-test"
5+
}
6+
7+
description = "Spring Boot Data ElasticSearch smoke test"
8+
9+
dependencies {
10+
dockerTestImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test"))
11+
dockerTestImplementation(project(":spring-boot-project:spring-boot-test"))
12+
dockerTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support-docker"))
13+
dockerTestImplementation(project(":spring-boot-project:spring-boot-testcontainers"))
14+
dockerTestImplementation("org.junit.jupiter:junit-jupiter")
15+
dockerTestImplementation("org.junit.platform:junit-platform-engine")
16+
dockerTestImplementation("org.junit.platform:junit-platform-launcher")
17+
dockerTestImplementation("org.testcontainers:junit-jupiter")
18+
dockerTestImplementation("org.testcontainers:elasticsearch")
19+
dockerTestImplementation("org.testcontainers:testcontainers")
20+
21+
implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-data-elasticsearch"))
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright 2012-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* 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,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package smoketest.data.elasticsearch;
18+
19+
import java.util.UUID;
20+
21+
import org.junit.jupiter.api.Test;
22+
import org.testcontainers.elasticsearch.ElasticsearchContainer;
23+
import org.testcontainers.junit.jupiter.Container;
24+
import org.testcontainers.junit.jupiter.Testcontainers;
25+
26+
import org.springframework.beans.factory.annotation.Autowired;
27+
import org.springframework.boot.test.autoconfigure.data.elasticsearch.DataElasticsearchTest;
28+
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
29+
import org.springframework.boot.testcontainers.service.connection.Ssl;
30+
import org.springframework.boot.testsupport.container.TestImage;
31+
import org.springframework.data.elasticsearch.client.elc.ElasticsearchTemplate;
32+
33+
import static org.assertj.core.api.Assertions.assertThat;
34+
35+
/**
36+
* Smoke tests for Elasticsearch 8.
37+
*
38+
* @author Moritz Halbritter
39+
*/
40+
@Testcontainers(disabledWithoutDocker = true)
41+
@DataElasticsearchTest
42+
class SampleElasticSearch8ApplicationTests {
43+
44+
@Container
45+
@ServiceConnection
46+
@Ssl
47+
static final ElasticsearchContainer elasticSearch = new ElasticsearchContainer(TestImage.ELASTICSEARCH_8.toString())
48+
.withPassword("my-custom-password");
49+
50+
@Autowired
51+
private ElasticsearchTemplate elasticsearchTemplate;
52+
53+
@Autowired
54+
private SampleRepository exampleRepository;
55+
56+
@Test
57+
void testRepository() {
58+
SampleDocument document = new SampleDocument();
59+
document.setText("Look, new @DataElasticsearchTest!");
60+
String id = UUID.randomUUID().toString();
61+
document.setId(id);
62+
SampleDocument savedDocument = this.exampleRepository.save(document);
63+
SampleDocument getDocument = this.elasticsearchTemplate.get(id, SampleDocument.class);
64+
assertThat(getDocument).isNotNull();
65+
assertThat(getDocument.getId()).isNotNull();
66+
assertThat(getDocument.getId()).isEqualTo(savedDocument.getId());
67+
this.exampleRepository.deleteAll();
68+
}
69+
70+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright 2012-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* 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,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package smoketest.data.elasticsearch;
18+
19+
import java.util.UUID;
20+
21+
import org.junit.jupiter.api.Test;
22+
import org.testcontainers.elasticsearch.ElasticsearchContainer;
23+
import org.testcontainers.junit.jupiter.Container;
24+
import org.testcontainers.junit.jupiter.Testcontainers;
25+
26+
import org.springframework.beans.factory.annotation.Autowired;
27+
import org.springframework.boot.test.autoconfigure.data.elasticsearch.DataElasticsearchTest;
28+
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
29+
import org.springframework.boot.testsupport.container.TestImage;
30+
import org.springframework.data.elasticsearch.client.elc.ElasticsearchTemplate;
31+
32+
import static org.assertj.core.api.Assertions.assertThat;
33+
34+
/**
35+
* Smoke tests for Elasticsearch.
36+
*
37+
* @author Moritz Halbritter
38+
*/
39+
@Testcontainers(disabledWithoutDocker = true)
40+
@DataElasticsearchTest
41+
class SampleElasticSearchApplicationTests {
42+
43+
@Container
44+
@ServiceConnection
45+
static final ElasticsearchContainer elasticSearch = TestImage.container(ElasticsearchContainer.class);
46+
47+
@Autowired
48+
private ElasticsearchTemplate elasticsearchTemplate;
49+
50+
@Autowired
51+
private SampleRepository exampleRepository;
52+
53+
@Test
54+
void testRepository() {
55+
SampleDocument document = new SampleDocument();
56+
document.setText("Look, new @DataElasticsearchTest!");
57+
String id = UUID.randomUUID().toString();
58+
document.setId(id);
59+
SampleDocument savedDocument = this.exampleRepository.save(document);
60+
SampleDocument getDocument = this.elasticsearchTemplate.get(id, SampleDocument.class);
61+
assertThat(getDocument).isNotNull();
62+
assertThat(getDocument.getId()).isNotNull();
63+
assertThat(getDocument.getId()).isEqualTo(savedDocument.getId());
64+
this.exampleRepository.deleteAll();
65+
}
66+
67+
}

0 commit comments

Comments
 (0)