Add SSL service connection support for Kafka

See gh-41137
This commit is contained in:
Moritz Halbritter 2025-02-11 10:23:55 +01:00
parent 789d30deab
commit dae891f473
19 changed files with 478 additions and 112 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.
@ -23,7 +23,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading;
import org.springframework.boot.autoconfigure.thread.Threading;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
@ -152,11 +151,10 @@ class KafkaAnnotationDrivenConfiguration {
ConcurrentKafkaListenerContainerFactory<?, ?> kafkaListenerContainerFactory(
ConcurrentKafkaListenerContainerFactoryConfigurer configurer,
ObjectProvider<ConsumerFactory<Object, Object>> kafkaConsumerFactory,
ObjectProvider<ContainerCustomizer<Object, Object, ConcurrentMessageListenerContainer<Object, Object>>> kafkaContainerCustomizer,
ObjectProvider<SslBundles> sslBundles) {
ObjectProvider<ContainerCustomizer<Object, Object, ConcurrentMessageListenerContainer<Object, Object>>> kafkaContainerCustomizer) {
ConcurrentKafkaListenerContainerFactory<Object, Object> factory = new ConcurrentKafkaListenerContainerFactory<>();
configurer.configure(factory, kafkaConsumerFactory.getIfAvailable(() -> new DefaultKafkaConsumerFactory<>(
this.properties.buildConsumerProperties(sslBundles.getIfAvailable()))));
configurer.configure(factory, kafkaConsumerFactory
.getIfAvailable(() -> new DefaultKafkaConsumerFactory<>(this.properties.buildConsumerProperties())));
kafkaContainerCustomizer.ifAvailable(factory::setContainerCustomizer);
return factory;
}

View File

@ -23,6 +23,7 @@ import java.util.Map;
import org.apache.kafka.clients.CommonClientConfigs;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.config.SslConfigs;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
@ -32,10 +33,12 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate;
import org.springframework.boot.autoconfigure.kafka.KafkaConnectionDetails.Configuration;
import org.springframework.boot.autoconfigure.kafka.KafkaProperties.Jaas;
import org.springframework.boot.autoconfigure.kafka.KafkaProperties.Retry.Topic.Backoff;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.context.properties.PropertyMapper;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
@ -54,6 +57,7 @@ import org.springframework.kafka.support.converter.RecordMessageConverter;
import org.springframework.kafka.transaction.KafkaTransactionManager;
import org.springframework.retry.backoff.BackOffPolicyBuilder;
import org.springframework.retry.backoff.SleepingBackOffPolicy;
import org.springframework.util.StringUtils;
/**
* {@link EnableAutoConfiguration Auto-configuration} for Apache Kafka.
@ -84,8 +88,9 @@ public class KafkaAutoConfiguration {
@Bean
@ConditionalOnMissingBean(KafkaConnectionDetails.class)
PropertiesKafkaConnectionDetails kafkaConnectionDetails(KafkaProperties properties) {
return new PropertiesKafkaConnectionDetails(properties);
PropertiesKafkaConnectionDetails kafkaConnectionDetails(KafkaProperties properties,
ObjectProvider<SslBundles> sslBundles) {
return new PropertiesKafkaConnectionDetails(properties, sslBundles.getIfAvailable());
}
@Bean
@ -111,9 +116,9 @@ public class KafkaAutoConfiguration {
@Bean
@ConditionalOnMissingBean(ConsumerFactory.class)
public DefaultKafkaConsumerFactory<?, ?> kafkaConsumerFactory(KafkaConnectionDetails connectionDetails,
ObjectProvider<DefaultKafkaConsumerFactoryCustomizer> customizers, ObjectProvider<SslBundles> sslBundles) {
Map<String, Object> properties = this.properties.buildConsumerProperties(sslBundles.getIfAvailable());
DefaultKafkaConsumerFactory<?, ?> kafkaConsumerFactory(KafkaConnectionDetails connectionDetails,
ObjectProvider<DefaultKafkaConsumerFactoryCustomizer> customizers) {
Map<String, Object> properties = this.properties.buildConsumerProperties();
applyKafkaConnectionDetailsForConsumer(properties, connectionDetails);
DefaultKafkaConsumerFactory<Object, Object> factory = new DefaultKafkaConsumerFactory<>(properties);
customizers.orderedStream().forEach((customizer) -> customizer.customize(factory));
@ -122,9 +127,9 @@ public class KafkaAutoConfiguration {
@Bean
@ConditionalOnMissingBean(ProducerFactory.class)
public DefaultKafkaProducerFactory<?, ?> kafkaProducerFactory(KafkaConnectionDetails connectionDetails,
ObjectProvider<DefaultKafkaProducerFactoryCustomizer> customizers, ObjectProvider<SslBundles> sslBundles) {
Map<String, Object> properties = this.properties.buildProducerProperties(sslBundles.getIfAvailable());
DefaultKafkaProducerFactory<?, ?> kafkaProducerFactory(KafkaConnectionDetails connectionDetails,
ObjectProvider<DefaultKafkaProducerFactoryCustomizer> customizers) {
Map<String, Object> properties = this.properties.buildProducerProperties();
applyKafkaConnectionDetailsForProducer(properties, connectionDetails);
DefaultKafkaProducerFactory<?, ?> factory = new DefaultKafkaProducerFactory<>(properties);
String transactionIdPrefix = this.properties.getProducer().getTransactionIdPrefix();
@ -160,8 +165,8 @@ public class KafkaAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public KafkaAdmin kafkaAdmin(KafkaConnectionDetails connectionDetails, ObjectProvider<SslBundles> sslBundles) {
Map<String, Object> properties = this.properties.buildAdminProperties(sslBundles.getIfAvailable());
KafkaAdmin kafkaAdmin(KafkaConnectionDetails connectionDetails) {
Map<String, Object> properties = this.properties.buildAdminProperties(null);
applyKafkaConnectionDetailsForAdmin(properties, connectionDetails);
KafkaAdmin kafkaAdmin = new KafkaAdmin(properties);
KafkaProperties.Admin admin = this.properties.getAdmin();
@ -193,26 +198,26 @@ public class KafkaAutoConfiguration {
private void applyKafkaConnectionDetailsForConsumer(Map<String, Object> properties,
KafkaConnectionDetails connectionDetails) {
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, connectionDetails.getConsumerBootstrapServers());
if (!(connectionDetails instanceof PropertiesKafkaConnectionDetails)) {
properties.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "PLAINTEXT");
}
Configuration consumer = connectionDetails.getConsumer();
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, consumer.getBootstrapServers());
applySecurityProtocol(properties, connectionDetails.getSecurityProtocol());
applySslBundle(properties, consumer.getSslBundle());
}
private void applyKafkaConnectionDetailsForProducer(Map<String, Object> properties,
KafkaConnectionDetails connectionDetails) {
properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, connectionDetails.getProducerBootstrapServers());
if (!(connectionDetails instanceof PropertiesKafkaConnectionDetails)) {
properties.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "PLAINTEXT");
}
Configuration producer = connectionDetails.getProducer();
properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, producer.getBootstrapServers());
applySecurityProtocol(properties, producer.getSecurityProtocol());
applySslBundle(properties, producer.getSslBundle());
}
private void applyKafkaConnectionDetailsForAdmin(Map<String, Object> properties,
KafkaConnectionDetails connectionDetails) {
properties.put(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, connectionDetails.getAdminBootstrapServers());
if (!(connectionDetails instanceof PropertiesKafkaConnectionDetails)) {
properties.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "PLAINTEXT");
}
Configuration admin = connectionDetails.getAdmin();
properties.put(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, admin.getBootstrapServers());
applySecurityProtocol(properties, admin.getSecurityProtocol());
applySslBundle(properties, admin.getSslBundle());
}
private static void setBackOffPolicy(RetryTopicConfigurationBuilder builder, Backoff retryTopicBackoff) {
@ -231,4 +236,17 @@ public class KafkaAutoConfiguration {
}
}
static void applySslBundle(Map<String, Object> properties, SslBundle sslBundle) {
if (sslBundle != null) {
properties.put(SslConfigs.SSL_ENGINE_FACTORY_CLASS_CONFIG, SslBundleSslEngineFactory.class.getName());
properties.put(SslBundle.class.getName(), sslBundle);
}
}
static void applySecurityProtocol(Map<String, Object> properties, String securityProtocol) {
if (StringUtils.hasLength(securityProtocol)) {
properties.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, securityProtocol);
}
}
}

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.
@ -19,6 +19,7 @@ package org.springframework.boot.autoconfigure.kafka;
import java.util.List;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
import org.springframework.boot.ssl.SslBundle;
/**
* Details required to establish a connection to a Kafka service.
@ -36,36 +37,173 @@ public interface KafkaConnectionDetails extends ConnectionDetails {
*/
List<String> getBootstrapServers();
/**
* Returns the SSL bundle.
* @return the SSL bundle
* @since 3.5.0
*/
default SslBundle getSslBundle() {
return null;
}
/**
* Returns the security protocol.
* @return the security protocol
* @since 3.5.0
*/
default String getSecurityProtocol() {
return null;
}
/**
* Returns the consumer configuration.
* @return the consumer configuration
* @since 3.5.0
*/
default Configuration getConsumer() {
return Configuration.of(getBootstrapServers(), getSslBundle(), getSecurityProtocol());
}
/**
* Returns the producer configuration.
* @return the producer configuration
* @since 3.5.0
*/
default Configuration getProducer() {
return Configuration.of(getBootstrapServers(), getSslBundle(), getSecurityProtocol());
}
/**
* Returns the admin configuration.
* @return the admin configuration
* @since 3.5.0
*/
default Configuration getAdmin() {
return Configuration.of(getBootstrapServers(), getSslBundle(), getSecurityProtocol());
}
/**
* Returns the Kafka Streams configuration.
* @return the Kafka Streams configuration
* @since 3.5.0
*/
default Configuration getStreams() {
return Configuration.of(getBootstrapServers(), getSslBundle(), getSecurityProtocol());
}
/**
* Returns the list of bootstrap servers used for consumers.
* @return the list of bootstrap servers used for consumers
* @deprecated since 3.5.0 for removal in 3.7.0 in favor of {@link #getConsumer()}
*/
@Deprecated(since = "3.5.0", forRemoval = true)
default List<String> getConsumerBootstrapServers() {
return getBootstrapServers();
return getConsumer().getBootstrapServers();
}
/**
* Returns the list of bootstrap servers used for producers.
* @return the list of bootstrap servers used for producers
* @deprecated since 3.5.0 for removal in 3.7.0 in favor of {@link #getProducer()}
*/
@Deprecated(since = "3.5.0", forRemoval = true)
default List<String> getProducerBootstrapServers() {
return getBootstrapServers();
return getProducer().getBootstrapServers();
}
/**
* Returns the list of bootstrap servers used for the admin.
* @return the list of bootstrap servers used for the admin
* @deprecated since 3.5.0 for removal in 3.7.0 in favor of {@link #getAdmin()}
*/
@Deprecated(since = "3.5.0", forRemoval = true)
default List<String> getAdminBootstrapServers() {
return getBootstrapServers();
return getAdmin().getBootstrapServers();
}
/**
* Returns the list of bootstrap servers used for Kafka Streams.
* @return the list of bootstrap servers used for Kafka Streams
* @deprecated since 3.5.0 for removal in 3.7.0 in favor of {@link #getStreams()}
*/
@Deprecated(since = "3.5.0", forRemoval = true)
default List<String> getStreamsBootstrapServers() {
return getBootstrapServers();
return getStreams().getBootstrapServers();
}
/**
* Kafka connection details configuration.
*/
interface Configuration {
/**
* Creates a new configuration with the given bootstrap servers.
* @param bootstrapServers the bootstrap servers
* @return the configuration
*/
static Configuration of(List<String> bootstrapServers) {
return Configuration.of(bootstrapServers, null, null);
}
/**
* Creates a new configuration with the given bootstrap servers and SSL bundle.
* @param bootstrapServers the bootstrap servers
* @param sslBundle the SSL bundle
* @return the configuration
*/
static Configuration of(List<String> bootstrapServers, SslBundle sslBundle) {
return Configuration.of(bootstrapServers, sslBundle, null);
}
/**
* Creates a new configuration with the given bootstrap servers, SSL bundle and
* security protocol.
* @param bootstrapServers the bootstrap servers
* @param sslBundle the SSL bundle
* @param securityProtocol the security protocol
* @return the configuration
*/
static Configuration of(List<String> bootstrapServers, SslBundle sslBundle, String securityProtocol) {
return new Configuration() {
@Override
public List<String> getBootstrapServers() {
return bootstrapServers;
}
@Override
public SslBundle getSslBundle() {
return sslBundle;
}
@Override
public String getSecurityProtocol() {
return securityProtocol;
}
};
}
/**
* Returns the list of bootstrap servers.
* @return the list of bootstrap servers
*/
List<String> getBootstrapServers();
/**
* Returns the SSL bundle.
* @return the SSL bundle
*/
default SslBundle getSslBundle() {
return null;
}
/**
* Returns the security protocol.
* @return the security protocol
*/
default String getSecurityProtocol() {
return null;
}
}
}

View File

@ -38,7 +38,6 @@ import org.springframework.boot.context.properties.DeprecatedConfigurationProper
import org.springframework.boot.context.properties.PropertyMapper;
import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException;
import org.springframework.boot.convert.DurationUnit;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.core.io.Resource;
import org.springframework.kafka.listener.ContainerProperties.AckMode;
@ -1401,10 +1400,10 @@ public class KafkaProperties {
public Map<String, Object> buildProperties(SslBundles sslBundles) {
validate();
String bundleName = getBundle();
if (StringUtils.hasText(bundleName)) {
return buildPropertiesForSslBundle(sslBundles, bundleName);
}
Properties properties = new Properties();
if (StringUtils.hasText(bundleName)) {
return properties;
}
PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
map.from(this::getKeyPassword).to(properties.in(SslConfigs.SSL_KEY_PASSWORD_CONFIG));
map.from(this::getKeyStoreCertificateChain)
@ -1425,13 +1424,6 @@ public class KafkaProperties {
return properties;
}
private Map<String, Object> buildPropertiesForSslBundle(SslBundles sslBundles, String name) {
Properties properties = new Properties();
properties.in(SslConfigs.SSL_ENGINE_FACTORY_CLASS_CONFIG).accept(SslBundleSslEngineFactory.class.getName());
properties.in(SslBundle.class.getName()).accept(sslBundles.getBundle(name));
return properties;
}
private void validate() {
MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleMatchingValuesIn((entries) -> {
entries.put("spring.kafka.ssl.key-store-key", getKeyStoreKey());

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.
@ -18,7 +18,6 @@ package org.springframework.boot.autoconfigure.kafka;
import java.util.Map;
import org.apache.kafka.clients.CommonClientConfigs;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.streams.StreamsBuilder;
import org.apache.kafka.streams.StreamsConfig;
@ -30,7 +29,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
@ -63,8 +61,8 @@ class KafkaStreamsAnnotationDrivenConfiguration {
@ConditionalOnMissingBean
@Bean(KafkaStreamsDefaultConfiguration.DEFAULT_STREAMS_CONFIG_BEAN_NAME)
KafkaStreamsConfiguration defaultKafkaStreamsConfig(Environment environment,
KafkaConnectionDetails connectionDetails, ObjectProvider<SslBundles> sslBundles) {
Map<String, Object> properties = this.properties.buildStreamsProperties(sslBundles.getIfAvailable());
KafkaConnectionDetails connectionDetails) {
Map<String, Object> properties = this.properties.buildStreamsProperties(null);
applyKafkaConnectionDetailsForStreams(properties, connectionDetails);
if (this.properties.getStreams().getApplicationId() == null) {
String applicationName = environment.getProperty("spring.application.name");
@ -87,10 +85,10 @@ class KafkaStreamsAnnotationDrivenConfiguration {
private void applyKafkaConnectionDetailsForStreams(Map<String, Object> properties,
KafkaConnectionDetails connectionDetails) {
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, connectionDetails.getStreamsBootstrapServers());
if (!(connectionDetails instanceof PropertiesKafkaConnectionDetails)) {
properties.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "PLAINTEXT");
}
KafkaConnectionDetails.Configuration streams = connectionDetails.getStreams();
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, streams.getBootstrapServers());
KafkaAutoConfiguration.applySecurityProtocol(properties, streams.getSecurityProtocol());
KafkaAutoConfiguration.applySslBundle(properties, streams.getSslBundle());
}
// Separate class required to avoid BeanCurrentlyInCreationException

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.
@ -18,6 +18,12 @@ package org.springframework.boot.autoconfigure.kafka;
import java.util.List;
import org.springframework.boot.autoconfigure.kafka.KafkaProperties.Ssl;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Adapts {@link KafkaProperties} to {@link KafkaConnectionDetails}.
*
@ -29,8 +35,11 @@ class PropertiesKafkaConnectionDetails implements KafkaConnectionDetails {
private final KafkaProperties properties;
PropertiesKafkaConnectionDetails(KafkaProperties properties) {
private final SslBundles sslBundles;
PropertiesKafkaConnectionDetails(KafkaProperties properties, SslBundles sslBundles) {
this.properties = properties;
this.sslBundles = sslBundles;
}
@Override
@ -39,22 +48,59 @@ class PropertiesKafkaConnectionDetails implements KafkaConnectionDetails {
}
@Override
public List<String> getConsumerBootstrapServers() {
return getServers(this.properties.getConsumer().getBootstrapServers());
public Configuration getConsumer() {
List<String> servers = this.properties.getConsumer().getBootstrapServers();
SslBundle sslBundle = getBundle(this.properties.getConsumer().getSsl());
String protocol = this.properties.getConsumer().getSecurity().getProtocol();
return Configuration.of((servers != null) ? servers : getBootstrapServers(),
(sslBundle != null) ? sslBundle : getSslBundle(),
(StringUtils.hasLength(protocol)) ? protocol : getSecurityProtocol());
}
@Override
public List<String> getProducerBootstrapServers() {
return getServers(this.properties.getProducer().getBootstrapServers());
public Configuration getProducer() {
List<String> servers = this.properties.getProducer().getBootstrapServers();
SslBundle sslBundle = getBundle(this.properties.getProducer().getSsl());
String protocol = this.properties.getProducer().getSecurity().getProtocol();
return Configuration.of((servers != null) ? servers : getBootstrapServers(),
(sslBundle != null) ? sslBundle : getSslBundle(),
(StringUtils.hasLength(protocol)) ? protocol : getSecurityProtocol());
}
@Override
public List<String> getStreamsBootstrapServers() {
return getServers(this.properties.getStreams().getBootstrapServers());
public Configuration getStreams() {
List<String> servers = this.properties.getStreams().getBootstrapServers();
SslBundle sslBundle = getBundle(this.properties.getStreams().getSsl());
String protocol = this.properties.getStreams().getSecurity().getProtocol();
return Configuration.of((servers != null) ? servers : getBootstrapServers(),
(sslBundle != null) ? sslBundle : getSslBundle(),
(StringUtils.hasLength(protocol)) ? protocol : getSecurityProtocol());
}
private List<String> getServers(List<String> servers) {
return (servers != null) ? servers : getBootstrapServers();
@Override
public Configuration getAdmin() {
SslBundle sslBundle = getBundle(this.properties.getAdmin().getSsl());
String protocol = this.properties.getAdmin().getSecurity().getProtocol();
return Configuration.of(getBootstrapServers(), (sslBundle != null) ? sslBundle : getSslBundle(),
(StringUtils.hasLength(protocol)) ? protocol : getSecurityProtocol());
}
@Override
public SslBundle getSslBundle() {
return getBundle(this.properties.getSsl());
}
@Override
public String getSecurityProtocol() {
return this.properties.getSecurity().getProtocol();
}
private SslBundle getBundle(Ssl ssl) {
if (StringUtils.hasLength(ssl.getBundle())) {
Assert.notNull(this.sslBundles, "SSL bundle name has been set but no SSL bundles found in context");
return this.sslBundles.getBundle(ssl.getBundle());
}
return null;
}
}

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.
@ -47,6 +47,8 @@ import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslStoreBundle;
import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.boot.test.context.runner.ContextConsumer;
@ -192,10 +194,34 @@ class KafkaAutoConfigurationTests {
Collections.singletonList("kafka.example.com:12345"));
assertThat(configs).containsEntry(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,
Collections.singletonList("kafka.example.com:12345"));
assertThat(configs).containsEntry(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "PLAINTEXT");
});
}
@Test
void connectionDetailsWithSslBundleAreAppliedToConsumer() {
SslBundle sslBundle = SslBundle.of(SslStoreBundle.NONE);
KafkaConnectionDetails connectionDetails = new KafkaConnectionDetails() {
@Override
public List<String> getBootstrapServers() {
return List.of("kafka.example.com:12345");
}
@Override
public Configuration getConsumer() {
return Configuration.of(getBootstrapServers(), sslBundle);
}
};
this.contextRunner.withBean(KafkaConnectionDetails.class, () -> connectionDetails).run((context) -> {
assertThat(context).hasSingleBean(KafkaConnectionDetails.class);
DefaultKafkaConsumerFactory<?, ?> consumerFactory = context.getBean(DefaultKafkaConsumerFactory.class);
Map<String, Object> configs = consumerFactory.getConfigurationProperties();
assertThat(configs).containsEntry("ssl.engine.factory.class",
"org.springframework.boot.autoconfigure.kafka.SslBundleSslEngineFactory");
assertThat(configs).containsEntry("org.springframework.boot.ssl.SslBundle", sslBundle);
});
}
@Test
void producerProperties() {
this.contextRunner.withPropertyValues("spring.kafka.clientId=cid",
@ -262,10 +288,34 @@ class KafkaAutoConfigurationTests {
Collections.singletonList("kafka.example.com:12345"));
assertThat(configs).containsEntry(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,
Collections.singletonList("kafka.example.com:12345"));
assertThat(configs).containsEntry(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "PLAINTEXT");
});
}
@Test
void connectionDetailsWithSslBundleAreAppliedToProducer() {
SslBundle sslBundle = SslBundle.of(SslStoreBundle.NONE);
KafkaConnectionDetails connectionDetails = new KafkaConnectionDetails() {
@Override
public List<String> getBootstrapServers() {
return List.of("kafka.example.com:12345");
}
@Override
public Configuration getProducer() {
return Configuration.of(getBootstrapServers(), sslBundle);
}
};
this.contextRunner.withBean(KafkaConnectionDetails.class, () -> connectionDetails).run((context) -> {
assertThat(context).hasSingleBean(KafkaConnectionDetails.class);
DefaultKafkaProducerFactory<?, ?> producerFactory = context.getBean(DefaultKafkaProducerFactory.class);
Map<String, Object> configs = producerFactory.getConfigurationProperties();
assertThat(configs).containsEntry("ssl.engine.factory.class",
"org.springframework.boot.autoconfigure.kafka.SslBundleSslEngineFactory");
assertThat(configs).containsEntry("org.springframework.boot.ssl.SslBundle", sslBundle);
});
}
@Test
void adminProperties() {
this.contextRunner
@ -322,11 +372,34 @@ class KafkaAutoConfigurationTests {
Collections.singletonList("kafka.example.com:12345"));
assertThat(configs).containsEntry(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG,
Collections.singletonList("kafka.example.com:12345"));
assertThat(configs).containsEntry(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "PLAINTEXT");
assertThat(configs).containsEntry(AdminClientConfig.SECURITY_PROTOCOL_CONFIG, "PLAINTEXT");
});
}
@Test
void connectionDetailsWithSslBundleAreAppliedToAdmin() {
SslBundle sslBundle = SslBundle.of(SslStoreBundle.NONE);
KafkaConnectionDetails connectionDetails = new KafkaConnectionDetails() {
@Override
public List<String> getBootstrapServers() {
return List.of("kafka.example.com:12345");
}
@Override
public Configuration getAdmin() {
return Configuration.of(getBootstrapServers(), sslBundle);
}
};
this.contextRunner.withBean(KafkaConnectionDetails.class, () -> connectionDetails).run((context) -> {
assertThat(context).hasSingleBean(KafkaConnectionDetails.class);
KafkaAdmin admin = context.getBean(KafkaAdmin.class);
Map<String, Object> configs = admin.getConfigurationProperties();
assertThat(configs).containsEntry("ssl.engine.factory.class",
"org.springframework.boot.autoconfigure.kafka.SslBundleSslEngineFactory");
assertThat(configs).containsEntry("org.springframework.boot.ssl.SslBundle", sslBundle);
});
}
@SuppressWarnings("unchecked")
@Test
void streamsProperties() {
@ -391,8 +464,35 @@ class KafkaAutoConfigurationTests {
Collections.singletonList("kafka.example.com:12345"));
assertThat(configs).containsEntry(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG,
Collections.singletonList("kafka.example.com:12345"));
assertThat(configs).containsEntry(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "PLAINTEXT");
assertThat(configs).containsEntry(StreamsConfig.SECURITY_PROTOCOL_CONFIG, "PLAINTEXT");
});
}
@Test
void connectionDetailsWithSslBundleAreAppliedToStreams() {
SslBundle sslBundle = SslBundle.of(SslStoreBundle.NONE);
KafkaConnectionDetails connectionDetails = new KafkaConnectionDetails() {
@Override
public List<String> getBootstrapServers() {
return List.of("kafka.example.com:12345");
}
@Override
public Configuration getStreams() {
return Configuration.of(getBootstrapServers(), sslBundle);
}
};
this.contextRunner.withUserConfiguration(EnableKafkaStreamsConfiguration.class)
.withPropertyValues("spring.kafka.streams.auto-startup=false", "spring.kafka.streams.application-id=test")
.withBean(KafkaConnectionDetails.class, () -> connectionDetails)
.run((context) -> {
assertThat(context).hasSingleBean(KafkaConnectionDetails.class);
Properties configs = context
.getBean(KafkaStreamsDefaultConfiguration.DEFAULT_STREAMS_CONFIG_BEAN_NAME,
KafkaStreamsConfiguration.class)
.asProperties();
assertThat(configs).containsEntry("ssl.engine.factory.class",
"org.springframework.boot.autoconfigure.kafka.SslBundleSslEngineFactory");
assertThat(configs).containsEntry("org.springframework.boot.ssl.SslBundle", sslBundle);
});
}

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.
@ -107,8 +107,7 @@ class KafkaPropertiesTests {
properties.getSsl().setBundle("myBundle");
Map<String, Object> consumerProperties = properties
.buildConsumerProperties(new DefaultSslBundleRegistry("myBundle", this.sslBundle));
assertThat(consumerProperties).containsEntry(SslConfigs.SSL_ENGINE_FACTORY_CLASS_CONFIG,
SslBundleSslEngineFactory.class.getName());
assertThat(consumerProperties).doesNotContainKey(SslConfigs.SSL_ENGINE_FACTORY_CLASS_CONFIG);
}
@Test
@ -117,7 +116,7 @@ class KafkaPropertiesTests {
properties.getSsl().setKeyStoreKey("-----BEGIN");
properties.getSsl().setKeyStoreLocation(new ClassPathResource("ksLoc"));
assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class)
.isThrownBy(() -> properties.buildConsumerProperties());
.isThrownBy(properties::buildConsumerProperties);
}
@Test
@ -126,7 +125,7 @@ class KafkaPropertiesTests {
properties.getSsl().setTrustStoreLocation(new ClassPathResource("tsLoc"));
properties.getSsl().setTrustStoreCertificates("-----BEGIN");
assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class)
.isThrownBy(() -> properties.buildConsumerProperties());
.isThrownBy(properties::buildConsumerProperties);
}
@Test

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.
@ -21,6 +21,7 @@ import java.util.List;
import org.testcontainers.kafka.KafkaContainer;
import org.springframework.boot.autoconfigure.kafka.KafkaConnectionDetails;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory;
import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
@ -57,6 +58,16 @@ class ApacheKafkaContainerConnectionDetailsFactory
return List.of(getContainer().getBootstrapServers());
}
@Override
public SslBundle getSslBundle() {
return super.getSslBundle();
}
@Override
public String getSecurityProtocol() {
return (getSslBundle() != null) ? "SSL" : "PLAINTEXT";
}
}
}

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.
@ -21,6 +21,7 @@ import java.util.List;
import org.testcontainers.kafka.ConfluentKafkaContainer;
import org.springframework.boot.autoconfigure.kafka.KafkaConnectionDetails;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory;
import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
@ -58,6 +59,16 @@ class ConfluentKafkaContainerConnectionDetailsFactory
return List.of(getContainer().getBootstrapServers());
}
@Override
public SslBundle getSslBundle() {
return super.getSslBundle();
}
@Override
public String getSecurityProtocol() {
return (getSslBundle() != null) ? "SSL" : "PLAINTEXT";
}
}
}

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.
@ -21,6 +21,7 @@ import java.util.List;
import org.testcontainers.containers.KafkaContainer;
import org.springframework.boot.autoconfigure.kafka.KafkaConnectionDetails;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory;
import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
@ -59,6 +60,16 @@ class DeprecatedConfluentKafkaContainerConnectionDetailsFactory
return List.of(getContainer().getBootstrapServers());
}
@Override
public SslBundle getSslBundle() {
return super.getSslBundle();
}
@Override
public String getSecurityProtocol() {
return (getSslBundle() != null) ? "SSL" : "PLAINTEXT";
}
}
}

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.
@ -21,6 +21,7 @@ import java.util.List;
import org.testcontainers.redpanda.RedpandaContainer;
import org.springframework.boot.autoconfigure.kafka.KafkaConnectionDetails;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory;
import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
@ -55,6 +56,16 @@ class RedpandaContainerConnectionDetailsFactory
return List.of(getContainer().getBootstrapServers());
}
@Override
public SslBundle getSslBundle() {
return super.getSslBundle();
}
@Override
public String getSecurityProtocol() {
return (getSslBundle() != null) ? "SSL" : "PLAINTEXT";
}
}
}

View File

@ -18,6 +18,7 @@ configurations.all {
dependencies {
dockerTestImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test"))
dockerTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support-docker"))
dockerTestImplementation(project(":spring-boot-project:spring-boot-testcontainers"))
dockerTestImplementation("org.awaitility:awaitility")
dockerTestImplementation("org.testcontainers:junit-jupiter")
dockerTestImplementation("org.testcontainers:kafka")

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.
@ -23,16 +23,16 @@ import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.kafka.ConfluentKafkaContainer;
import org.testcontainers.utility.MountableFile;
import smoketest.kafka.Consumer;
import smoketest.kafka.Producer;
import smoketest.kafka.SampleMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.JksKeyStore;
import org.springframework.boot.testcontainers.service.connection.JksTrustStore;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.boot.testsupport.container.TestImage;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.empty;
@ -45,38 +45,14 @@ import static org.hamcrest.Matchers.not;
* @author Eddú Meléndez
*/
@Testcontainers(disabledWithoutDocker = true)
@SpringBootTest(classes = { SampleKafkaSslApplication.class, Producer.class, Consumer.class },
properties = { "spring.kafka.security.protocol=SSL",
"spring.kafka.properties.ssl.endpoint.identification.algorithm=", "spring.kafka.ssl.bundle=client",
"spring.ssl.bundle.jks.client.keystore.location=classpath:ssl/test-client.p12",
"spring.ssl.bundle.jks.client.keystore.password=password",
"spring.ssl.bundle.jks.client.truststore.location=classpath:ssl/test-ca.p12",
"spring.ssl.bundle.jks.client.truststore.password=password" })
@SpringBootTest(classes = { SampleKafkaSslApplication.class, Producer.class, Consumer.class })
class SampleKafkaSslApplicationTests {
@Container
public static ConfluentKafkaContainer kafka = TestImage.container(ConfluentKafkaContainer.class)
.withEnv("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", "PLAINTEXT:SSL,BROKER:PLAINTEXT,CONTROLLER:PLAINTEXT")
.withEnv("KAFKA_AUTO_CREATE_TOPICS_ENABLE", "true")
.withEnv("KAFKA_SSL_CLIENT_AUTH", "required")
.withEnv("KAFKA_SSL_KEYSTORE_LOCATION", "/etc/kafka/secrets/certs/test-server.p12")
.withEnv("KAFKA_SSL_KEYSTORE_PASSWORD", "password")
.withEnv("KAFKA_SSL_KEY_PASSWORD", "password")
.withEnv("KAFKA_SSL_TRUSTSTORE_LOCATION", "/etc/kafka/secrets/certs/test-ca.p12")
.withEnv("KAFKA_SSL_TRUSTSTORE_PASSWORD", "password")
.withEnv("KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM", "")
.withCopyFileToContainer(MountableFile.forClasspathResource("ssl/test-server.p12"),
"/etc/kafka/secrets/certs/test-server.p12")
.withCopyFileToContainer(MountableFile.forClasspathResource("ssl/credentials"),
"/etc/kafka/secrets/certs/credentials")
.withCopyFileToContainer(MountableFile.forClasspathResource("ssl/test-ca.p12"),
"/etc/kafka/secrets/certs/test-ca.p12");
@DynamicPropertySource
static void kafkaProperties(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers",
() -> String.format("%s:%s", kafka.getHost(), kafka.getMappedPort(9092)));
}
@ServiceConnection
@JksTrustStore(location = "classpath:ssl/test-ca.p12", password = "password")
@JksKeyStore(location = "classpath:ssl/test-client.p12", password = "password")
public static ConfluentKafkaContainer kafka = TestImage.container(SecureKafkaContainer.class);
@Autowired
private Producer producer;

View File

@ -0,0 +1,56 @@
/*
* 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 smoketest.kafka.ssl;
import org.testcontainers.kafka.ConfluentKafkaContainer;
import org.testcontainers.utility.DockerImageName;
import org.testcontainers.utility.MountableFile;
/**
* Kafka container with SSL enabled.
*
* @author Scott Frederick
* @author Eddú Meléndez
* @author Moritz Halbritter
*/
class SecureKafkaContainer extends ConfluentKafkaContainer {
SecureKafkaContainer(DockerImageName dockerImageName) {
super(dockerImageName);
}
@Override
protected void configure() {
super.configure();
withEnv("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", "PLAINTEXT:SSL,BROKER:PLAINTEXT,CONTROLLER:PLAINTEXT")
.withEnv("KAFKA_AUTO_CREATE_TOPICS_ENABLE", "true")
.withEnv("KAFKA_SSL_CLIENT_AUTH", "required")
.withEnv("KAFKA_SSL_KEYSTORE_LOCATION", "/etc/kafka/secrets/certs/test-server.p12")
.withEnv("KAFKA_SSL_KEYSTORE_PASSWORD", "password")
.withEnv("KAFKA_SSL_KEY_PASSWORD", "password")
.withEnv("KAFKA_SSL_TRUSTSTORE_LOCATION", "/etc/kafka/secrets/certs/test-ca.p12")
.withEnv("KAFKA_SSL_TRUSTSTORE_PASSWORD", "password")
.withEnv("KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM", "");
withCopyFileToContainer(MountableFile.forClasspathResource("ssl/test-server.p12"),
"/etc/kafka/secrets/certs/test-server.p12");
withCopyFileToContainer(MountableFile.forClasspathResource("ssl/credentials"),
"/etc/kafka/secrets/certs/credentials");
withCopyFileToContainer(MountableFile.forClasspathResource("ssl/test-ca.p12"),
"/etc/kafka/secrets/certs/test-ca.p12");
}
}