Skip to content

Commit b62a0c1

Browse files
mhalbritterphilwebb
andcommitted
Add SSL support to Docker Compose and Testcontainers infrastructure
See gh-41137 Co-authored-by: Phillip Webb <phil.webb@broadcom.com>
1 parent 528b7e9 commit b62a0c1

20 files changed

+776
-53
lines changed

spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DefaultRunningService.java

+9-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.
@@ -45,6 +45,8 @@ class DefaultRunningService implements RunningService, OriginProvider {
4545

4646
private final DockerEnv env;
4747

48+
private final DockerComposeFile composeFile;
49+
4850
DefaultRunningService(DockerHost host, DockerComposeFile composeFile, DockerCliComposePsResponse composePsResponse,
4951
DockerCliInspectResponse inspectResponse) {
5052
this.origin = new DockerComposeOrigin(composeFile, composePsResponse.name());
@@ -55,6 +57,7 @@ class DefaultRunningService implements RunningService, OriginProvider {
5557
this.ports = new DefaultConnectionPorts(inspectResponse);
5658
this.env = new DockerEnv(inspectResponse.config().env());
5759
this.labels = Collections.unmodifiableMap(inspectResponse.config().labels());
60+
this.composeFile = composeFile;
5861
}
5962

6063
@Override
@@ -97,4 +100,9 @@ public String toString() {
97100
return this.name;
98101
}
99102

103+
@Override
104+
public DockerComposeFile composeFile() {
105+
return this.composeFile;
106+
}
107+
100108
}

spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/RunningService.java

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2024 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.
@@ -64,4 +64,13 @@ public interface RunningService {
6464
*/
6565
Map<String, String> labels();
6666

67+
/**
68+
* Return the Docker Compose file for the service.
69+
* @return the Docker Compose file
70+
* @since 3.5.0
71+
*/
72+
default DockerComposeFile composeFile() {
73+
return null;
74+
}
75+
6776
}

spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/DockerComposeConnectionDetailsFactory.java

+125
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,33 @@
1616

1717
package org.springframework.boot.docker.compose.service.connection;
1818

19+
import java.nio.file.Path;
1920
import java.util.Arrays;
21+
import java.util.Set;
2022
import java.util.function.Predicate;
2123

2224
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
2325
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory;
26+
import org.springframework.boot.docker.compose.core.DockerComposeFile;
2427
import org.springframework.boot.docker.compose.core.RunningService;
28+
import org.springframework.boot.io.ApplicationResourceLoader;
2529
import org.springframework.boot.origin.Origin;
2630
import org.springframework.boot.origin.OriginProvider;
31+
import org.springframework.boot.ssl.SslBundle;
32+
import org.springframework.boot.ssl.SslBundleKey;
33+
import org.springframework.boot.ssl.SslOptions;
34+
import org.springframework.boot.ssl.jks.JksSslStoreBundle;
35+
import org.springframework.boot.ssl.jks.JksSslStoreDetails;
36+
import org.springframework.boot.ssl.pem.PemSslStore;
37+
import org.springframework.boot.ssl.pem.PemSslStoreBundle;
38+
import org.springframework.boot.ssl.pem.PemSslStoreDetails;
39+
import org.springframework.core.io.ResourceLoader;
40+
import org.springframework.core.io.support.SpringFactoriesLoader;
2741
import org.springframework.util.Assert;
2842
import org.springframework.util.ClassUtils;
43+
import org.springframework.util.CollectionUtils;
2944
import org.springframework.util.ObjectUtils;
45+
import org.springframework.util.StringUtils;
3046

3147
/**
3248
* Base class for {@link ConnectionDetailsFactory} implementations that provide
@@ -106,6 +122,8 @@ protected static class DockerComposeConnectionDetails implements ConnectionDetai
106122

107123
private final Origin origin;
108124

125+
private volatile SslBundle sslBundle;
126+
109127
/**
110128
* Create a new {@link DockerComposeConnectionDetails} instance.
111129
* @param runningService the source {@link RunningService}
@@ -120,6 +138,113 @@ public Origin getOrigin() {
120138
return this.origin;
121139
}
122140

141+
protected SslBundle getSslBundle(RunningService service) {
142+
if (this.sslBundle != null) {
143+
return this.sslBundle;
144+
}
145+
SslBundle jksSslBundle = getJksSslBundle(service);
146+
SslBundle pemSslBundle = getPemSslBundle(service);
147+
if (jksSslBundle == null && pemSslBundle == null) {
148+
return null;
149+
}
150+
if (jksSslBundle != null && pemSslBundle != null) {
151+
throw new IllegalStateException("Mutually exclusive JKS and PEM ssl bundles have been configured");
152+
}
153+
SslBundle sslBundle = (jksSslBundle != null) ? jksSslBundle : pemSslBundle;
154+
this.sslBundle = sslBundle;
155+
return sslBundle;
156+
}
157+
158+
private SslBundle getJksSslBundle(RunningService service) {
159+
JksSslStoreDetails keyStoreDetails = getJksSslStoreDetails(service, "keystore");
160+
JksSslStoreDetails trustStoreDetails = getJksSslStoreDetails(service, "truststore");
161+
if (keyStoreDetails == null && trustStoreDetails == null) {
162+
return null;
163+
}
164+
SslBundleKey key = SslBundleKey.of(service.labels().get("org.springframework.boot.sslbundle.jks.key.alias"),
165+
service.labels().get("org.springframework.boot.sslbundle.jks.key.password"));
166+
SslOptions options = createSslOptions(
167+
service.labels().get("org.springframework.boot.sslbundle.jks.options.ciphers"),
168+
service.labels().get("org.springframework.boot.sslbundle.jks.options.enabled-protocols"));
169+
String protocol = service.labels().get("org.springframework.boot.sslbundle.jks.protocol");
170+
Path workingDirectory = getWorkingDirectory(service);
171+
return SslBundle.of(
172+
new JksSslStoreBundle(keyStoreDetails, trustStoreDetails, getResourceLoader(workingDirectory)), key,
173+
options, protocol);
174+
}
175+
176+
private ResourceLoader getResourceLoader(Path workingDirectory) {
177+
ClassLoader classLoader = ApplicationResourceLoader.get().getClassLoader();
178+
return ApplicationResourceLoader.get(classLoader,
179+
SpringFactoriesLoader.forDefaultResourceLocation(classLoader), workingDirectory);
180+
}
181+
182+
private JksSslStoreDetails getJksSslStoreDetails(RunningService service, String storeType) {
183+
String type = service.labels().get("org.springframework.boot.sslbundle.jks.%s.type".formatted(storeType));
184+
String provider = service.labels()
185+
.get("org.springframework.boot.sslbundle.jks.%s.provider".formatted(storeType));
186+
String location = service.labels()
187+
.get("org.springframework.boot.sslbundle.jks.%s.location".formatted(storeType));
188+
String password = service.labels()
189+
.get("org.springframework.boot.sslbundle.jks.%s.password".formatted(storeType));
190+
if (location == null) {
191+
return null;
192+
}
193+
return new JksSslStoreDetails(type, provider, location, password);
194+
}
195+
196+
private Path getWorkingDirectory(RunningService runningService) {
197+
DockerComposeFile composeFile = runningService.composeFile();
198+
if (composeFile == null || CollectionUtils.isEmpty(composeFile.getFiles())) {
199+
return Path.of(".");
200+
}
201+
return composeFile.getFiles().get(0).toPath().getParent();
202+
}
203+
204+
private SslOptions createSslOptions(String ciphers, String enabledProtocols) {
205+
Set<String> ciphersSet = null;
206+
if (StringUtils.hasLength(ciphers)) {
207+
ciphersSet = StringUtils.commaDelimitedListToSet(ciphers);
208+
}
209+
Set<String> enabledProtocolsSet = null;
210+
if (StringUtils.hasLength(enabledProtocols)) {
211+
enabledProtocolsSet = StringUtils.commaDelimitedListToSet(enabledProtocols);
212+
}
213+
return SslOptions.of(ciphersSet, enabledProtocolsSet);
214+
}
215+
216+
private SslBundle getPemSslBundle(RunningService service) {
217+
PemSslStoreDetails keyStoreDetails = getPemSslStoreDetails(service, "keystore");
218+
PemSslStoreDetails trustStoreDetails = getPemSslStoreDetails(service, "truststore");
219+
if (keyStoreDetails == null && trustStoreDetails == null) {
220+
return null;
221+
}
222+
SslBundleKey key = SslBundleKey.of(service.labels().get("org.springframework.boot.sslbundle.pem.key.alias"),
223+
service.labels().get("org.springframework.boot.sslbundle.pem.key.password"));
224+
SslOptions options = createSslOptions(
225+
service.labels().get("org.springframework.boot.sslbundle.pem.options.ciphers"),
226+
service.labels().get("org.springframework.boot.sslbundle.pem.options.enabled-protocols"));
227+
String protocol = service.labels().get("org.springframework.boot.sslbundle.pem.protocol");
228+
Path workingDirectory = getWorkingDirectory(service);
229+
ResourceLoader resourceLoader = getResourceLoader(workingDirectory);
230+
return SslBundle.of(new PemSslStoreBundle(PemSslStore.load(keyStoreDetails, resourceLoader),
231+
PemSslStore.load(trustStoreDetails, resourceLoader)), key, options, protocol);
232+
}
233+
234+
private PemSslStoreDetails getPemSslStoreDetails(RunningService service, String storeType) {
235+
String type = service.labels().get("org.springframework.boot.sslbundle.pem.%s.type".formatted(storeType));
236+
String certificate = service.labels()
237+
.get("org.springframework.boot.sslbundle.pem.%s.certificate".formatted(storeType));
238+
String privateKey = service.labels()
239+
.get("org.springframework.boot.sslbundle.pem.%s.private-key".formatted(storeType));
240+
String privateKeyPassword = service.labels()
241+
.get("org.springframework.boot.sslbundle.pem.%s.private-key-password".formatted(storeType));
242+
if (certificate == null && privateKey == null) {
243+
return null;
244+
}
245+
return new PemSslStoreDetails(type, certificate, privateKey, privateKeyPassword);
246+
}
247+
123248
}
124249

125250
}

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

+31
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

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

19+
import java.lang.annotation.Annotation;
1920
import java.util.Arrays;
2021
import java.util.List;
2122
import java.util.stream.Stream;
@@ -33,6 +34,7 @@
3334
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory;
3435
import org.springframework.boot.origin.Origin;
3536
import org.springframework.boot.origin.OriginProvider;
37+
import org.springframework.boot.ssl.SslBundle;
3638
import org.springframework.boot.testcontainers.lifecycle.TestcontainersStartup;
3739
import org.springframework.context.ApplicationContext;
3840
import org.springframework.context.ApplicationContextAware;
@@ -166,6 +168,8 @@ protected static class ContainerConnectionDetails<C extends Container<?>>
166168

167169
private volatile C container;
168170

171+
private volatile SslBundle sslBundle;
172+
169173
/**
170174
* Create a new {@link ContainerConnectionDetails} instance.
171175
* @param source the source {@link ContainerConnectionSource}
@@ -194,6 +198,33 @@ protected final C getContainer() {
194198
return this.container;
195199
}
196200

201+
/**
202+
* Return the {@link SslBundle} to use with this connection or {@code null}.
203+
* @return the ssl bundle or {@code null}
204+
* @since 3.5.0
205+
*/
206+
protected SslBundle getSslBundle() {
207+
if (this.source.getSslBundleSource() == null) {
208+
return null;
209+
}
210+
SslBundle sslBundle = this.sslBundle;
211+
if (sslBundle == null) {
212+
sslBundle = this.source.getSslBundleSource().getSslBundle();
213+
this.sslBundle = sslBundle;
214+
}
215+
return sslBundle;
216+
}
217+
218+
/**
219+
* Whether the field or bean is annotated with the given annotation.
220+
* @param annotationType the annotation to check
221+
* @return whether the field or bean is annotated with the annotation
222+
* @since 3.5.0
223+
*/
224+
protected boolean hasAnnotation(Class<? extends Annotation> annotationType) {
225+
return this.source.hasAnnotation(annotationType);
226+
}
227+
197228
@Override
198229
public Origin getOrigin() {
199230
return this.source.getOrigin();

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

+26-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2024 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,6 +16,7 @@
1616

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

19+
import java.lang.annotation.Annotation;
1920
import java.util.Set;
2021
import java.util.function.Supplier;
2122

@@ -27,6 +28,7 @@
2728
import org.springframework.boot.origin.Origin;
2829
import org.springframework.boot.origin.OriginProvider;
2930
import org.springframework.core.annotation.MergedAnnotation;
31+
import org.springframework.core.annotation.MergedAnnotations;
3032
import org.springframework.core.log.LogMessage;
3133
import org.springframework.util.StringUtils;
3234

@@ -60,26 +62,36 @@ public final class ContainerConnectionSource<C extends Container<?>> implements
6062

6163
private final Supplier<C> containerSupplier;
6264

65+
private final SslBundleSource sslBundleSource;
66+
67+
private final MergedAnnotations annotations;
68+
6369
ContainerConnectionSource(String beanNameSuffix, Origin origin, Class<C> containerType, String containerImageName,
64-
MergedAnnotation<ServiceConnection> annotation, Supplier<C> containerSupplier) {
70+
MergedAnnotation<ServiceConnection> annotation, Supplier<C> containerSupplier,
71+
SslBundleSource sslBundleSource, MergedAnnotations annotations) {
6572
this.beanNameSuffix = beanNameSuffix;
6673
this.origin = origin;
6774
this.containerType = containerType;
6875
this.containerImageName = containerImageName;
6976
this.connectionName = getOrDeduceConnectionName(annotation.getString("name"), containerImageName);
7077
this.connectionDetailsTypes = Set.of(annotation.getClassArray("type"));
7178
this.containerSupplier = containerSupplier;
79+
this.sslBundleSource = sslBundleSource;
80+
this.annotations = annotations;
7281
}
7382

7483
ContainerConnectionSource(String beanNameSuffix, Origin origin, Class<C> containerType, String containerImageName,
75-
ServiceConnection annotation, Supplier<C> containerSupplier) {
84+
ServiceConnection annotation, Supplier<C> containerSupplier, SslBundleSource sslBundleSource,
85+
MergedAnnotations annotations) {
7686
this.beanNameSuffix = beanNameSuffix;
7787
this.origin = origin;
7888
this.containerType = containerType;
7989
this.containerImageName = containerImageName;
8090
this.connectionName = getOrDeduceConnectionName(annotation.name(), containerImageName);
8191
this.connectionDetailsTypes = Set.of(annotation.type());
8292
this.containerSupplier = containerSupplier;
93+
this.sslBundleSource = sslBundleSource;
94+
this.annotations = annotations;
8395
}
8496

8597
private static String getOrDeduceConnectionName(String connectionName, String containerImageName) {
@@ -156,6 +168,17 @@ Set<Class<?>> getConnectionDetailsTypes() {
156168
return this.connectionDetailsTypes;
157169
}
158170

171+
SslBundleSource getSslBundleSource() {
172+
return this.sslBundleSource;
173+
}
174+
175+
boolean hasAnnotation(Class<? extends Annotation> annotationType) {
176+
if (this.annotations == null) {
177+
return false;
178+
}
179+
return this.annotations.isPresent(annotationType);
180+
}
181+
159182
@Override
160183
public String toString() {
161184
return "@ServiceConnection source for %s".formatted(this.origin);

0 commit comments

Comments
 (0)