Add SSL support to Docker Compose and Testcontainers infrastructure

See gh-41137

Co-authored-by: Phillip Webb <phil.webb@broadcom.com>
This commit is contained in:
Moritz Halbritter 2025-02-11 10:15:58 +01:00
parent 528b7e9ad9
commit b62a0c1ae0
20 changed files with 776 additions and 53 deletions

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -45,6 +45,8 @@ class DefaultRunningService implements RunningService, OriginProvider {
private final DockerEnv env;
private final DockerComposeFile composeFile;
DefaultRunningService(DockerHost host, DockerComposeFile composeFile, DockerCliComposePsResponse composePsResponse,
DockerCliInspectResponse inspectResponse) {
this.origin = new DockerComposeOrigin(composeFile, composePsResponse.name());
@ -55,6 +57,7 @@ class DefaultRunningService implements RunningService, OriginProvider {
this.ports = new DefaultConnectionPorts(inspectResponse);
this.env = new DockerEnv(inspectResponse.config().env());
this.labels = Collections.unmodifiableMap(inspectResponse.config().labels());
this.composeFile = composeFile;
}
@Override
@ -97,4 +100,9 @@ class DefaultRunningService implements RunningService, OriginProvider {
return this.name;
}
@Override
public DockerComposeFile composeFile() {
return this.composeFile;
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2024 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -64,4 +64,13 @@ public interface RunningService {
*/
Map<String, String> labels();
/**
* Return the Docker Compose file for the service.
* @return the Docker Compose file
* @since 3.5.0
*/
default DockerComposeFile composeFile() {
return null;
}
}

View File

@ -16,17 +16,33 @@
package org.springframework.boot.docker.compose.service.connection;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Set;
import java.util.function.Predicate;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory;
import org.springframework.boot.docker.compose.core.DockerComposeFile;
import org.springframework.boot.docker.compose.core.RunningService;
import org.springframework.boot.io.ApplicationResourceLoader;
import org.springframework.boot.origin.Origin;
import org.springframework.boot.origin.OriginProvider;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundleKey;
import org.springframework.boot.ssl.SslOptions;
import org.springframework.boot.ssl.jks.JksSslStoreBundle;
import org.springframework.boot.ssl.jks.JksSslStoreDetails;
import org.springframework.boot.ssl.pem.PemSslStore;
import org.springframework.boot.ssl.pem.PemSslStoreBundle;
import org.springframework.boot.ssl.pem.PemSslStoreDetails;
import org.springframework.core.io.ResourceLoader;
import org.springframework.core.io.support.SpringFactoriesLoader;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
/**
* Base class for {@link ConnectionDetailsFactory} implementations that provide
@ -106,6 +122,8 @@ public abstract class DockerComposeConnectionDetailsFactory<D extends Connection
private final Origin origin;
private volatile SslBundle sslBundle;
/**
* Create a new {@link DockerComposeConnectionDetails} instance.
* @param runningService the source {@link RunningService}
@ -120,6 +138,113 @@ public abstract class DockerComposeConnectionDetailsFactory<D extends Connection
return this.origin;
}
protected SslBundle getSslBundle(RunningService service) {
if (this.sslBundle != null) {
return this.sslBundle;
}
SslBundle jksSslBundle = getJksSslBundle(service);
SslBundle pemSslBundle = getPemSslBundle(service);
if (jksSslBundle == null && pemSslBundle == null) {
return null;
}
if (jksSslBundle != null && pemSslBundle != null) {
throw new IllegalStateException("Mutually exclusive JKS and PEM ssl bundles have been configured");
}
SslBundle sslBundle = (jksSslBundle != null) ? jksSslBundle : pemSslBundle;
this.sslBundle = sslBundle;
return sslBundle;
}
private SslBundle getJksSslBundle(RunningService service) {
JksSslStoreDetails keyStoreDetails = getJksSslStoreDetails(service, "keystore");
JksSslStoreDetails trustStoreDetails = getJksSslStoreDetails(service, "truststore");
if (keyStoreDetails == null && trustStoreDetails == null) {
return null;
}
SslBundleKey key = SslBundleKey.of(service.labels().get("org.springframework.boot.sslbundle.jks.key.alias"),
service.labels().get("org.springframework.boot.sslbundle.jks.key.password"));
SslOptions options = createSslOptions(
service.labels().get("org.springframework.boot.sslbundle.jks.options.ciphers"),
service.labels().get("org.springframework.boot.sslbundle.jks.options.enabled-protocols"));
String protocol = service.labels().get("org.springframework.boot.sslbundle.jks.protocol");
Path workingDirectory = getWorkingDirectory(service);
return SslBundle.of(
new JksSslStoreBundle(keyStoreDetails, trustStoreDetails, getResourceLoader(workingDirectory)), key,
options, protocol);
}
private ResourceLoader getResourceLoader(Path workingDirectory) {
ClassLoader classLoader = ApplicationResourceLoader.get().getClassLoader();
return ApplicationResourceLoader.get(classLoader,
SpringFactoriesLoader.forDefaultResourceLocation(classLoader), workingDirectory);
}
private JksSslStoreDetails getJksSslStoreDetails(RunningService service, String storeType) {
String type = service.labels().get("org.springframework.boot.sslbundle.jks.%s.type".formatted(storeType));
String provider = service.labels()
.get("org.springframework.boot.sslbundle.jks.%s.provider".formatted(storeType));
String location = service.labels()
.get("org.springframework.boot.sslbundle.jks.%s.location".formatted(storeType));
String password = service.labels()
.get("org.springframework.boot.sslbundle.jks.%s.password".formatted(storeType));
if (location == null) {
return null;
}
return new JksSslStoreDetails(type, provider, location, password);
}
private Path getWorkingDirectory(RunningService runningService) {
DockerComposeFile composeFile = runningService.composeFile();
if (composeFile == null || CollectionUtils.isEmpty(composeFile.getFiles())) {
return Path.of(".");
}
return composeFile.getFiles().get(0).toPath().getParent();
}
private SslOptions createSslOptions(String ciphers, String enabledProtocols) {
Set<String> ciphersSet = null;
if (StringUtils.hasLength(ciphers)) {
ciphersSet = StringUtils.commaDelimitedListToSet(ciphers);
}
Set<String> enabledProtocolsSet = null;
if (StringUtils.hasLength(enabledProtocols)) {
enabledProtocolsSet = StringUtils.commaDelimitedListToSet(enabledProtocols);
}
return SslOptions.of(ciphersSet, enabledProtocolsSet);
}
private SslBundle getPemSslBundle(RunningService service) {
PemSslStoreDetails keyStoreDetails = getPemSslStoreDetails(service, "keystore");
PemSslStoreDetails trustStoreDetails = getPemSslStoreDetails(service, "truststore");
if (keyStoreDetails == null && trustStoreDetails == null) {
return null;
}
SslBundleKey key = SslBundleKey.of(service.labels().get("org.springframework.boot.sslbundle.pem.key.alias"),
service.labels().get("org.springframework.boot.sslbundle.pem.key.password"));
SslOptions options = createSslOptions(
service.labels().get("org.springframework.boot.sslbundle.pem.options.ciphers"),
service.labels().get("org.springframework.boot.sslbundle.pem.options.enabled-protocols"));
String protocol = service.labels().get("org.springframework.boot.sslbundle.pem.protocol");
Path workingDirectory = getWorkingDirectory(service);
ResourceLoader resourceLoader = getResourceLoader(workingDirectory);
return SslBundle.of(new PemSslStoreBundle(PemSslStore.load(keyStoreDetails, resourceLoader),
PemSslStore.load(trustStoreDetails, resourceLoader)), key, options, protocol);
}
private PemSslStoreDetails getPemSslStoreDetails(RunningService service, String storeType) {
String type = service.labels().get("org.springframework.boot.sslbundle.pem.%s.type".formatted(storeType));
String certificate = service.labels()
.get("org.springframework.boot.sslbundle.pem.%s.certificate".formatted(storeType));
String privateKey = service.labels()
.get("org.springframework.boot.sslbundle.pem.%s.private-key".formatted(storeType));
String privateKeyPassword = service.labels()
.get("org.springframework.boot.sslbundle.pem.%s.private-key-password".formatted(storeType));
if (certificate == null && privateKey == null) {
return null;
}
return new PemSslStoreDetails(type, certificate, privateKey, privateKeyPassword);
}
}
}

View File

@ -16,6 +16,7 @@
package org.springframework.boot.testcontainers.service.connection;
import java.lang.annotation.Annotation;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
@ -33,6 +34,7 @@ import org.springframework.boot.autoconfigure.service.connection.ConnectionDetai
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory;
import org.springframework.boot.origin.Origin;
import org.springframework.boot.origin.OriginProvider;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.testcontainers.lifecycle.TestcontainersStartup;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
@ -166,6 +168,8 @@ public abstract class ContainerConnectionDetailsFactory<C extends Container<?>,
private volatile C container;
private volatile SslBundle sslBundle;
/**
* Create a new {@link ContainerConnectionDetails} instance.
* @param source the source {@link ContainerConnectionSource}
@ -194,6 +198,33 @@ public abstract class ContainerConnectionDetailsFactory<C extends Container<?>,
return this.container;
}
/**
* Return the {@link SslBundle} to use with this connection or {@code null}.
* @return the ssl bundle or {@code null}
* @since 3.5.0
*/
protected SslBundle getSslBundle() {
if (this.source.getSslBundleSource() == null) {
return null;
}
SslBundle sslBundle = this.sslBundle;
if (sslBundle == null) {
sslBundle = this.source.getSslBundleSource().getSslBundle();
this.sslBundle = sslBundle;
}
return sslBundle;
}
/**
* Whether the field or bean is annotated with the given annotation.
* @param annotationType the annotation to check
* @return whether the field or bean is annotated with the annotation
* @since 3.5.0
*/
protected boolean hasAnnotation(Class<? extends Annotation> annotationType) {
return this.source.hasAnnotation(annotationType);
}
@Override
public Origin getOrigin() {
return this.source.getOrigin();

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2024 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -16,6 +16,7 @@
package org.springframework.boot.testcontainers.service.connection;
import java.lang.annotation.Annotation;
import java.util.Set;
import java.util.function.Supplier;
@ -27,6 +28,7 @@ import org.testcontainers.utility.DockerImageName;
import org.springframework.boot.origin.Origin;
import org.springframework.boot.origin.OriginProvider;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.core.log.LogMessage;
import org.springframework.util.StringUtils;
@ -60,8 +62,13 @@ public final class ContainerConnectionSource<C extends Container<?>> implements
private final Supplier<C> containerSupplier;
private final SslBundleSource sslBundleSource;
private final MergedAnnotations annotations;
ContainerConnectionSource(String beanNameSuffix, Origin origin, Class<C> containerType, String containerImageName,
MergedAnnotation<ServiceConnection> annotation, Supplier<C> containerSupplier) {
MergedAnnotation<ServiceConnection> annotation, Supplier<C> containerSupplier,
SslBundleSource sslBundleSource, MergedAnnotations annotations) {
this.beanNameSuffix = beanNameSuffix;
this.origin = origin;
this.containerType = containerType;
@ -69,10 +76,13 @@ public final class ContainerConnectionSource<C extends Container<?>> implements
this.connectionName = getOrDeduceConnectionName(annotation.getString("name"), containerImageName);
this.connectionDetailsTypes = Set.of(annotation.getClassArray("type"));
this.containerSupplier = containerSupplier;
this.sslBundleSource = sslBundleSource;
this.annotations = annotations;
}
ContainerConnectionSource(String beanNameSuffix, Origin origin, Class<C> containerType, String containerImageName,
ServiceConnection annotation, Supplier<C> containerSupplier) {
ServiceConnection annotation, Supplier<C> containerSupplier, SslBundleSource sslBundleSource,
MergedAnnotations annotations) {
this.beanNameSuffix = beanNameSuffix;
this.origin = origin;
this.containerType = containerType;
@ -80,6 +90,8 @@ public final class ContainerConnectionSource<C extends Container<?>> implements
this.connectionName = getOrDeduceConnectionName(annotation.name(), containerImageName);
this.connectionDetailsTypes = Set.of(annotation.type());
this.containerSupplier = containerSupplier;
this.sslBundleSource = sslBundleSource;
this.annotations = annotations;
}
private static String getOrDeduceConnectionName(String connectionName, String containerImageName) {
@ -156,6 +168,17 @@ public final class ContainerConnectionSource<C extends Container<?>> implements
return this.connectionDetailsTypes;
}
SslBundleSource getSslBundleSource() {
return this.sslBundleSource;
}
boolean hasAnnotation(Class<? extends Annotation> annotationType) {
if (this.annotations == null) {
return false;
}
return this.annotations.isPresent(annotationType);
}
@Override
public String toString() {
return "@ServiceConnection source for %s".formatted(this.origin);

View File

@ -0,0 +1,71 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed 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 org.springframework.boot.testcontainers.service.connection;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.jks.JksSslStoreBundle;
import org.springframework.core.annotation.AliasFor;
/**
* Configures the {@link JksSslStoreBundle} key store to use with an {@link SslBundle SSL}
* supported {@link ServiceConnection @ServiceConnection}.
*
* @author Phillip Webb
* @since 3.5.0
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.ANNOTATION_TYPE })
public @interface JksKeyStore {
/**
* Alias for {@link #location()}.
* @return the store location
*/
@AliasFor("location")
String value() default "";
/**
* The location of the resource containing the store content.
* @return the store location
*/
@AliasFor("value")
String location() default "";
/**
* The password used to access the store.
* @return the store password
*/
String password() default "";
/**
* The type of the store to create, e.g. JKS.
* @return store type
*/
String type() default "";
/**
* The provider for the store.
* @return the store provider
*/
String provider() default "";
}

View File

@ -0,0 +1,71 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed 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 org.springframework.boot.testcontainers.service.connection;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.jks.JksSslStoreBundle;
import org.springframework.core.annotation.AliasFor;
/**
* Configures the {@link JksSslStoreBundle} trust store to use with an {@link SslBundle
* SSL} supported {@link ServiceConnection @ServiceConnection}.
*
* @author Phillip Webb
* @since 3.5.0
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.ANNOTATION_TYPE })
public @interface JksTrustStore {
/**
* Alias for {@link #location()}.
* @return the store location
*/
@AliasFor("location")
String value() default "";
/**
* The location of the resource containing the store content.
* @return the store location
*/
@AliasFor("value")
String location() default "";
/**
* The password used to access the store.
* @return the store password
*/
String password() default "";
/**
* The type of the store to create, e.g. JKS.
* @return store type
*/
String type() default "";
/**
* The provider for the store.
* @return the store provider
*/
String provider() default "";
}

View File

@ -0,0 +1,71 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed 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 org.springframework.boot.testcontainers.service.connection;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.pem.PemSslStoreBundle;
import org.springframework.core.annotation.AliasFor;
/**
* Configures the {@link PemSslStoreBundle} key store to use with an {@link SslBundle SSL}
* supported {@link ServiceConnection @ServiceConnection}.
*
* @author Phillip Webb
* @since 3.5.0
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.ANNOTATION_TYPE })
public @interface PemKeyStore {
/**
* Alias for {@link #certificate()}.
* @return the store certificate
*/
@AliasFor("certificate")
String value() default "";
/**
* The location or content of the certificate or certificate chain in PEM format.
* @return the store certificate location or content
*/
@AliasFor("value")
String certificate() default "";
/**
* The location or content of the private key in PEM format.
* @return the store private key location or content
*/
String privateKey() default "";
/**
* The password used to decrypt an encrypted private key.
* @return the store private key password
*/
String privateKeyPassword() default "";
/**
* The type of the store to create, e.g. JKS.
* @return the store type
*/
String type() default "";
}

View File

@ -0,0 +1,71 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed 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 org.springframework.boot.testcontainers.service.connection;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.pem.PemSslStoreBundle;
import org.springframework.core.annotation.AliasFor;
/**
* Configures the {@link PemSslStoreBundle} trust store to use with an {@link SslBundle
* SSL} supported {@link ServiceConnection @ServiceConnection}.
*
* @author Phillip Webb
* @since 3.5.0
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.ANNOTATION_TYPE })
public @interface PemTrustStore {
/**
* Alias for {@link #certificate()}.
* @return the store certificate
*/
@AliasFor("certificate")
String value() default "";
/**
* The location or content of the certificate or certificate chain in PEM format.
* @return the store certificate location or content
*/
@AliasFor("value")
String certificate() default "";
/**
* The location or content of the private key in PEM format.
* @return the store private key location or content
*/
String privateKey() default "";
/**
* The password used to decrypt an encrypted private key.
* @return the store private key password
*/
String privateKeyPassword() default "";
/**
* The type of the store to create, e.g. JKS.
* @return the store type
*/
String type() default "";
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -29,8 +29,13 @@ import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.AliasFor;
/**
* Annotation used to indicate that a field or method is a
* {@link ContainerConnectionSource} which provides a service that can be connected to.
* Indicates that a field or method is a {@link ContainerConnectionSource} which provides
* a service that can be connected to.
* <p>
* If the underling connection supports SSL, the {@link PemKeyStore @PemKeyStore},
* {@link PemTrustStore @PemTrustStore}, {@link JksKeyStore @JksKeyStore},
* {@link JksTrustStore @JksTrustStore}, {@link Ssl @Ssl} annotations may be used to
* provide additional configuration.
*
* @author Moritz Halbritter
* @author Andy Wilkinson

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2024 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -31,6 +31,7 @@ import org.springframework.boot.origin.Origin;
import org.springframework.boot.testcontainers.beans.TestcontainerBeanDefinition;
import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.core.type.AnnotationMetadata;
/**
@ -59,24 +60,27 @@ class ServiceConnectionAutoConfigurationRegistrar implements ImportBeanDefinitio
new ConnectionDetailsFactories());
for (String beanName : beanFactory.getBeanNamesForType(Container.class)) {
BeanDefinition beanDefinition = getBeanDefinition(beanFactory, beanName);
for (ServiceConnection annotation : getAnnotations(beanFactory, beanName, beanDefinition)) {
ContainerConnectionSource<?> source = createSource(beanFactory, beanName, beanDefinition, annotation);
MergedAnnotations annotations = (beanDefinition instanceof TestcontainerBeanDefinition testcontainerBeanDefinition)
? testcontainerBeanDefinition.getAnnotations() : null;
for (ServiceConnection serviceConnection : getServiceConnections(beanFactory, beanName, annotations)) {
ContainerConnectionSource<?> source = createSource(beanFactory, beanName, beanDefinition, annotations,
serviceConnection);
registrar.registerBeanDefinitions(registry, source);
}
}
}
private Set<ServiceConnection> getAnnotations(ConfigurableListableBeanFactory beanFactory, String beanName,
BeanDefinition beanDefinition) {
Set<ServiceConnection> annotations = new LinkedHashSet<>(
beanFactory.findAllAnnotationsOnBean(beanName, ServiceConnection.class, false));
if (beanDefinition instanceof TestcontainerBeanDefinition testcontainerBeanDefinition) {
testcontainerBeanDefinition.getAnnotations()
.stream(ServiceConnection.class)
private Set<ServiceConnection> getServiceConnections(ConfigurableListableBeanFactory beanFactory, String beanName,
MergedAnnotations annotations) {
Set<ServiceConnection> serviceConnections = beanFactory.findAllAnnotationsOnBean(beanName,
ServiceConnection.class, false);
if (annotations != null) {
serviceConnections = new LinkedHashSet<>(serviceConnections);
annotations.stream(ServiceConnection.class)
.map(MergedAnnotation::synthesize)
.forEach(annotations::add);
.forEach(serviceConnections::add);
}
return annotations;
return serviceConnections;
}
private BeanDefinition getBeanDefinition(ConfigurableListableBeanFactory beanFactory, String beanName) {
@ -91,13 +95,14 @@ class ServiceConnectionAutoConfigurationRegistrar implements ImportBeanDefinitio
@SuppressWarnings("unchecked")
private <C extends Container<?>> ContainerConnectionSource<C> createSource(
ConfigurableListableBeanFactory beanFactory, String beanName, BeanDefinition beanDefinition,
ServiceConnection annotation) {
MergedAnnotations annotations, ServiceConnection serviceConnection) {
Origin origin = new BeanOrigin(beanName, beanDefinition);
Class<C> containerType = (Class<C>) beanFactory.getType(beanName, false);
String containerImageName = (beanDefinition instanceof TestcontainerBeanDefinition testcontainerBeanDefinition)
? testcontainerBeanDefinition.getContainerImageName() : null;
return new ContainerConnectionSource<>(beanName, origin, containerType, containerImageName, annotation,
() -> beanFactory.getBean(beanName, containerType));
return new ContainerConnectionSource<>(beanName, origin, containerType, containerImageName, serviceConnection,
() -> beanFactory.getBean(beanName, containerType),
SslBundleSource.get(beanFactory, beanName, annotations), annotations);
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -91,10 +91,12 @@ class ServiceConnectionContextCustomizer implements ContextCustomizer {
* Relevant details from {@link ContainerConnectionSource} used as a
* MergedContextConfiguration cache key.
*/
private record CacheKey(String connectionName, Set<Class<?>> connectionDetailsTypes, Container<?> container) {
private record CacheKey(String connectionName, Set<Class<?>> connectionDetailsTypes, Container<?> container,
SslBundleSource sslBundleSource) {
CacheKey(ContainerConnectionSource<?> source) {
this(source.getConnectionName(), source.getConnectionDetailsTypes(), source.getContainerSupplier().get());
this(source.getConnectionName(), source.getConnectionDetailsTypes(), source.getContainerSupplier().get(),
source.getSslBundleSource());
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2024 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -61,7 +61,7 @@ class ServiceConnectionContextCustomizerFactory implements ContextCustomizerFact
ReflectionUtils.doWithLocalFields(candidate, (field) -> {
MergedAnnotations annotations = MergedAnnotations.from(field);
annotations.stream(ServiceConnection.class)
.forEach((annotation) -> sources.add(createSource(field, annotation)));
.forEach((serviceConnection) -> sources.add(createSource(field, annotations, serviceConnection)));
});
if (TestContextAnnotationUtils.searchEnclosingClass(candidate)) {
collectSources(candidate.getEnclosingClass(), sources);
@ -74,7 +74,7 @@ class ServiceConnectionContextCustomizerFactory implements ContextCustomizerFact
@SuppressWarnings("unchecked")
private <C extends Container<?>> ContainerConnectionSource<?> createSource(Field field,
MergedAnnotation<ServiceConnection> annotation) {
MergedAnnotations annotations, MergedAnnotation<ServiceConnection> serviceConnection) {
Assert.state(Modifier.isStatic(field.getModifiers()),
() -> "@ServiceConnection field '%s' must be static".formatted(field.getName()));
Origin origin = new FieldOrigin(field);
@ -87,8 +87,8 @@ class ServiceConnectionContextCustomizerFactory implements ContextCustomizerFact
// When running tests that doesn't matter, but running AOT processing should be
// possible without a Docker environment
String dockerImageName = isAotProcessingInProgress() ? null : container.getDockerImageName();
return new ContainerConnectionSource<>("test", origin, containerType, dockerImageName, annotation,
() -> container);
return new ContainerConnectionSource<>("test", origin, containerType, dockerImageName, serviceConnection,
() -> container, SslBundleSource.get(annotations), annotations);
}
private Object getFieldValue(Field field) {

View File

@ -0,0 +1,78 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed 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 org.springframework.boot.testcontainers.service.connection;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundleKey;
import org.springframework.boot.ssl.SslOptions;
/**
* Configures the {@link SslOptions}, {@link SslBundleKey @SslBundleKey} and
* {@link SslBundle#getProtocol() protocol} to use with an {@link SslBundle SSL} supported
* {@link ServiceConnection @ServiceConnection}.
* <p>
* Also serves as a signal to enable automatic {@link SslBundle} extraction from supported
* containers.
*
* @author Phillip Webb
* @since 3.5.0
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.ANNOTATION_TYPE })
public @interface Ssl {
/**
* The protocol to use for the SSL connection.
* @return the SSL protocol
* @see SslBundle#getProtocol()
*/
String protocol() default SslBundle.DEFAULT_PROTOCOL;
/**
* The ciphers that can be used for the SSL connection.
* @return the SSL ciphers
* @see SslOptions#getCiphers()
*/
String[] ciphers() default {};
/**
* The protocols that are enabled for the SSL connection.
* @return the enabled SSL protocols
* @see SslOptions#getEnabledProtocols()
*/
String[] enabledProtocols() default {};
/**
* The password that should be used to access the key.
* @return the key password
* @see SslBundleKey#getPassword()
*/
String keyPassword() default "";
/**
* The alias that should be used to access the key.
* @return the key alias
* @see SslBundleKey#getAlias()
*/
String keyAlias() default "";
}

View File

@ -0,0 +1,152 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed 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 org.springframework.boot.testcontainers.service.connection;
import java.lang.annotation.Annotation;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
import org.springframework.beans.factory.ListableBeanFactory;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundleKey;
import org.springframework.boot.ssl.SslOptions;
import org.springframework.boot.ssl.SslStoreBundle;
import org.springframework.boot.ssl.jks.JksSslStoreBundle;
import org.springframework.boot.ssl.jks.JksSslStoreDetails;
import org.springframework.boot.ssl.pem.PemSslStoreBundle;
import org.springframework.boot.ssl.pem.PemSslStoreDetails;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* {@link SslBundle} source created from annotations. Used as a cache key and as a
* {@link SslBundle} factory.
*
* @param ssl the {@link Ssl @Ssl} annotation
* @param pemKeyStore the {@link PemKeyStore @PemKeyStore} annotation
* @param pemTrustStore the {@link PemTrustStore @PemTrustStore} annotation
* @param jksKeyStore the {@link JksKeyStore @JksKeyStore} annotation
* @param jksTrustStore the {@link JksTrustStore @JksTrustStore} annotation
* @author Phillip Webb
* @author Moritz Halbritter
*/
record SslBundleSource(Ssl ssl, PemKeyStore pemKeyStore, PemTrustStore pemTrustStore, JksKeyStore jksKeyStore,
JksTrustStore jksTrustStore) {
SslBundleSource {
boolean hasPem = (pemKeyStore != null || pemTrustStore != null);
boolean hasJks = (jksKeyStore != null || jksTrustStore != null);
if (hasJks && hasPem) {
throw new IllegalStateException("PEM and JKS store annotations cannot be used together");
}
}
SslBundle getSslBundle() {
SslStoreBundle stores = stores();
if (stores == null) {
return null;
}
Ssl ssl = (this.ssl != null) ? this.ssl : MergedAnnotation.of(Ssl.class).synthesize();
SslOptions options = SslOptions.of(nullIfEmpty(ssl.ciphers()), nullIfEmpty(ssl.enabledProtocols()));
SslBundleKey key = SslBundleKey.of(nullIfEmpty(ssl.keyPassword()), nullIfEmpty(ssl.keyAlias()));
String protocol = ssl.protocol();
return SslBundle.of(stores, key, options, protocol);
}
private SslStoreBundle stores() {
if (this.pemKeyStore != null || this.pemTrustStore != null) {
return new PemSslStoreBundle(pemKeyStoreDetails(), pemTrustStoreDetails());
}
if (this.jksKeyStore != null || this.jksTrustStore != null) {
return new JksSslStoreBundle(jksKeyStoreDetails(), jksTrustStoreDetails());
}
return null;
}
private PemSslStoreDetails pemKeyStoreDetails() {
PemKeyStore store = this.pemKeyStore;
return (store != null) ? new PemSslStoreDetails(nullIfEmpty(store.type()), nullIfEmpty(store.certificate()),
nullIfEmpty(store.privateKey()), nullIfEmpty(store.privateKeyPassword())) : null;
}
private PemSslStoreDetails pemTrustStoreDetails() {
PemTrustStore store = this.pemTrustStore;
return (store != null) ? new PemSslStoreDetails(nullIfEmpty(store.type()), nullIfEmpty(store.certificate()),
nullIfEmpty(store.privateKey()), nullIfEmpty(store.privateKeyPassword())) : null;
}
private JksSslStoreDetails jksKeyStoreDetails() {
JksKeyStore store = this.jksKeyStore;
return (store != null) ? new JksSslStoreDetails(nullIfEmpty(store.type()), nullIfEmpty(store.provider()),
nullIfEmpty(store.location()), nullIfEmpty(store.password())) : null;
}
private JksSslStoreDetails jksTrustStoreDetails() {
JksTrustStore store = this.jksTrustStore;
return (store != null) ? new JksSslStoreDetails(nullIfEmpty(store.type()), nullIfEmpty(store.provider()),
nullIfEmpty(store.location()), nullIfEmpty(store.password())) : null;
}
private String nullIfEmpty(String string) {
if (StringUtils.hasLength(string)) {
return string;
}
return null;
}
private String[] nullIfEmpty(String[] array) {
if (array == null || array.length == 0) {
return null;
}
return array;
}
static SslBundleSource get(MergedAnnotations annotations) {
return get(null, null, annotations);
}
static SslBundleSource get(ListableBeanFactory beanFactory, String beanName, MergedAnnotations annotations) {
Ssl ssl = getAnnotation(beanFactory, beanName, annotations, Ssl.class);
PemKeyStore pemKeyStore = getAnnotation(beanFactory, beanName, annotations, PemKeyStore.class);
PemTrustStore pemTrustStore = getAnnotation(beanFactory, beanName, annotations, PemTrustStore.class);
JksKeyStore jksKeyStore = getAnnotation(beanFactory, beanName, annotations, JksKeyStore.class);
JksTrustStore jksTrustStore = getAnnotation(beanFactory, beanName, annotations, JksTrustStore.class);
if (ssl == null && pemKeyStore == null && pemTrustStore == null && jksKeyStore == null
&& jksTrustStore == null) {
return null;
}
return new SslBundleSource(ssl, pemKeyStore, pemTrustStore, jksKeyStore, jksTrustStore);
}
private static <A extends Annotation> A getAnnotation(ListableBeanFactory beanFactory, String beanName,
MergedAnnotations annotations, Class<A> annotationType) {
Set<A> found = (beanFactory != null) ? beanFactory.findAllAnnotationsOnBean(beanName, annotationType, false)
: Collections.emptySet();
if (annotations != null) {
found = new LinkedHashSet<>(found);
annotations.stream(annotationType).map(MergedAnnotation::synthesize).forEach(found::add);
}
int size = found.size();
Assert.state(size <= 1,
() -> "Expected single %s annotation, but found %d".formatted(annotationType.getName(), size));
return (size > 0) ? found.iterator().next() : null;
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -59,7 +59,7 @@ class ConnectionDetailsRegistrarTests {
this.container = mock(PostgreSQLContainer.class);
this.annotation = MergedAnnotation.of(ServiceConnection.class, Map.of("name", "", "type", new Class<?>[0]));
this.source = new ContainerConnectionSource<>("test", this.origin, PostgreSQLContainer.class, null,
this.annotation, () -> this.container);
this.annotation, () -> this.container, null, null);
this.factories = mock(ConnectionDetailsFactories.class);
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2024 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -65,7 +65,7 @@ class ContainerConnectionDetailsFactoryTests {
this.annotation = MergedAnnotation.of(ServiceConnection.class,
Map.of("name", "myname", "type", new Class<?>[0]));
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, PostgreSQLContainer.class,
this.container.getDockerImageName(), this.annotation, () -> this.container);
this.container.getDockerImageName(), this.annotation, () -> this.container, null, null);
}
@Test
@ -109,7 +109,8 @@ class ContainerConnectionDetailsFactoryTests {
void getConnectionDetailsWhenContainerTypeDoesNotMatchReturnsNull() {
ElasticsearchContainer container = mock(ElasticsearchContainer.class);
ContainerConnectionSource<?> source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin,
ElasticsearchContainer.class, container.getDockerImageName(), this.annotation, () -> container);
ElasticsearchContainer.class, container.getDockerImageName(), this.annotation, () -> container, null,
null);
TestContainerConnectionDetailsFactory factory = new TestContainerConnectionDetailsFactory();
ConnectionDetails connectionDetails = getConnectionDetails(factory, source);
assertThat(connectionDetails).isNull();

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -60,7 +60,7 @@ class ContainerConnectionSourceTests {
given(this.container.getDockerImageName()).willReturn("postgres");
this.annotation = MergedAnnotation.of(ServiceConnection.class, Map.of("name", "", "type", new Class<?>[0]));
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, PostgreSQLContainer.class,
this.container.getDockerImageName(), this.annotation, () -> this.container);
this.container.getDockerImageName(), this.annotation, () -> this.container, null, null);
}
@Test
@ -180,14 +180,14 @@ class ContainerConnectionSourceTests {
private void setupSourceAnnotatedWithName(String name) {
this.annotation = MergedAnnotation.of(ServiceConnection.class, Map.of("name", name, "type", new Class<?>[0]));
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, PostgreSQLContainer.class,
this.container.getDockerImageName(), this.annotation, () -> this.container);
this.container.getDockerImageName(), this.annotation, () -> this.container, null, null);
}
private void setupSourceAnnotatedWithType(Class<?> type) {
this.annotation = MergedAnnotation.of(ServiceConnection.class,
Map.of("name", "", "type", new Class<?>[] { type }));
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, PostgreSQLContainer.class,
this.container.getDockerImageName(), this.annotation, () -> this.container);
this.container.getDockerImageName(), this.annotation, () -> this.container, null, null);
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -66,7 +66,7 @@ class ServiceConnectionContextCustomizerTests {
this.annotation = MergedAnnotation.of(ServiceConnection.class,
Map.of("name", "myname", "type", new Class<?>[0]));
this.source = new ContainerConnectionSource<>("test", this.origin, PostgreSQLContainer.class,
this.container.getDockerImageName(), this.annotation, () -> this.container);
this.container.getDockerImageName(), this.annotation, () -> this.container, null, null);
this.factories = mock(ConnectionDetailsFactories.class);
}
@ -103,37 +103,37 @@ class ServiceConnectionContextCustomizerTests {
// Connection Names
ServiceConnectionContextCustomizer n1 = new ServiceConnectionContextCustomizer(
List.of(new ContainerConnectionSource<>("test", this.origin, PostgreSQLContainer.class, "name",
annotation1, () -> container1)));
annotation1, () -> container1, null, null)));
ServiceConnectionContextCustomizer n2 = new ServiceConnectionContextCustomizer(
List.of(new ContainerConnectionSource<>("test", this.origin, PostgreSQLContainer.class, "name",
annotation1, () -> container1)));
annotation1, () -> container1, null, null)));
ServiceConnectionContextCustomizer n3 = new ServiceConnectionContextCustomizer(
List.of(new ContainerConnectionSource<>("test", this.origin, PostgreSQLContainer.class, "namex",
annotation1, () -> container1)));
annotation1, () -> container1, null, null)));
assertThat(n1.hashCode()).isEqualTo(n2.hashCode()).isNotEqualTo(n3.hashCode());
assertThat(n1).isEqualTo(n2).isNotEqualTo(n3);
// Connection Details Types
ServiceConnectionContextCustomizer t1 = new ServiceConnectionContextCustomizer(
List.of(new ContainerConnectionSource<>("test", this.origin, PostgreSQLContainer.class, "name",
annotation1, () -> container1)));
annotation1, () -> container1, null, null)));
ServiceConnectionContextCustomizer t2 = new ServiceConnectionContextCustomizer(
List.of(new ContainerConnectionSource<>("test", this.origin, PostgreSQLContainer.class, "name",
annotation2, () -> container1)));
annotation2, () -> container1, null, null)));
ServiceConnectionContextCustomizer t3 = new ServiceConnectionContextCustomizer(
List.of(new ContainerConnectionSource<>("test", this.origin, PostgreSQLContainer.class, "name",
annotation3, () -> container1)));
annotation3, () -> container1, null, null)));
assertThat(t1.hashCode()).isEqualTo(t2.hashCode()).isNotEqualTo(t3.hashCode());
assertThat(t1).isEqualTo(t2).isNotEqualTo(t3);
// Container
ServiceConnectionContextCustomizer c1 = new ServiceConnectionContextCustomizer(
List.of(new ContainerConnectionSource<>("test", this.origin, PostgreSQLContainer.class, "name",
annotation1, () -> container1)));
annotation1, () -> container1, null, null)));
ServiceConnectionContextCustomizer c2 = new ServiceConnectionContextCustomizer(
List.of(new ContainerConnectionSource<>("test", this.origin, PostgreSQLContainer.class, "name",
annotation1, () -> container1)));
annotation1, () -> container1, null, null)));
ServiceConnectionContextCustomizer c3 = new ServiceConnectionContextCustomizer(
List.of(new ContainerConnectionSource<>("test", this.origin, PostgreSQLContainer.class, "name",
annotation1, () -> container2)));
annotation1, () -> container2, null, null)));
assertThat(c1.hashCode()).isEqualTo(c2.hashCode()).isNotEqualTo(c3.hashCode());
assertThat(c1).isEqualTo(c2).isNotEqualTo(c3);
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2024 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -37,7 +37,7 @@ public final class TestContainerConnectionSource {
Class<C> containerType, String containerImageName, MergedAnnotation<ServiceConnection> annotation,
Supplier<C> containerSupplier) {
return new ContainerConnectionSource<>(beanNameSuffix, origin, containerType, containerImageName, annotation,
containerSupplier);
containerSupplier, null, null);
}
}