Ensure containers are started before binding datasource properties

Update `TestcontainersLifecycleBeanPostProcessor` so that containers
are now initialized either on the first `postProcessAfterInitialization`
call with a frozen configuration or just before a test container
property is supplied.

Prior to this commit, it was assumed that the first post-process call
after the configuration was frozen was suitably early to initialize
the containers. This turns out to not be no always the case.

Specifically, in the `finishBeanFactoryInitialization` method of
`AbstractApplicationContext` we see that `LoadTimeWeaverAware` beans
are obtained before the configuration is frozen. One such bean is
`DefaultPersistenceUnitManager` which is likely to need datasource
properties that will require a started container.

To fix the problem, the `TestcontainersPropertySource` now publishes
a `BeforeTestcontainersPropertySuppliedEvent` to the ApplicationContext
just before any value is supplied. By listening for this event, we can
ensure that containers are initialized and started before any dynamic
property is read.

Fixes gh-38913
This commit is contained in:
Phillip Webb 2024-01-16 10:26:34 -08:00
parent f59fa2e3f7
commit 89874d351a
10 changed files with 313 additions and 39 deletions

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@ -20,6 +20,7 @@ import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Set;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.boot.testcontainers.properties.TestcontainersPropertySource;
import org.springframework.core.MethodIntrospector;
import org.springframework.core.annotation.MergedAnnotations;
@ -43,16 +44,17 @@ class DynamicPropertySourceMethodsImporter {
this.environment = environment;
}
void registerDynamicPropertySources(Class<?> definitionClass) {
void registerDynamicPropertySources(BeanDefinitionRegistry beanDefinitionRegistry, Class<?> definitionClass) {
Set<Method> methods = MethodIntrospector.selectMethods(definitionClass, this::isAnnotated);
if (methods.isEmpty()) {
return;
}
DynamicPropertyRegistry registry = TestcontainersPropertySource.attach(this.environment);
DynamicPropertyRegistry dynamicPropertyRegistry = TestcontainersPropertySource.attach(this.environment,
beanDefinitionRegistry);
methods.forEach((method) -> {
assertValid(method);
ReflectionUtils.makeAccessible(method);
ReflectionUtils.invokeMethod(method, null, registry);
ReflectionUtils.invokeMethod(method, null, dynamicPropertyRegistry);
});
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@ -62,7 +62,7 @@ class ImportTestcontainersRegistrar implements ImportBeanDefinitionRegistrar {
for (Class<?> definitionClass : definitionClasses) {
this.containerFieldsImporter.registerBeanDefinitions(registry, definitionClass);
if (this.dynamicPropertySourceMethodsImporter != null) {
this.dynamicPropertySourceMethodsImporter.registerDynamicPropertySources(definitionClass);
this.dynamicPropertySourceMethodsImporter.registerDynamicPropertySources(registry, definitionClass);
}
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@ -48,7 +48,10 @@ public class TestcontainersLifecycleApplicationContextInitializer
ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory();
applicationContext.addBeanFactoryPostProcessor(new TestcontainersLifecycleBeanFactoryPostProcessor());
TestcontainersStartup startup = TestcontainersStartup.get(applicationContext.getEnvironment());
beanFactory.addBeanPostProcessor(new TestcontainersLifecycleBeanPostProcessor(beanFactory, startup));
TestcontainersLifecycleBeanPostProcessor beanPostProcessor = new TestcontainersLifecycleBeanPostProcessor(
beanFactory, startup);
beanFactory.addBeanPostProcessor(beanPostProcessor);
applicationContext.addApplicationListener(beanPostProcessor);
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@ -38,6 +38,8 @@ import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.config.DestructionAwareBeanPostProcessor;
import org.springframework.boot.testcontainers.properties.BeforeTestcontainersPropertySuppliedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.core.log.LogMessage;
@ -56,7 +58,8 @@ import org.springframework.core.log.LogMessage;
* @see TestcontainersLifecycleApplicationContextInitializer
*/
@Order(Ordered.LOWEST_PRECEDENCE)
class TestcontainersLifecycleBeanPostProcessor implements DestructionAwareBeanPostProcessor {
class TestcontainersLifecycleBeanPostProcessor
implements DestructionAwareBeanPostProcessor, ApplicationListener<BeforeTestcontainersPropertySuppliedEvent> {
private static final Log logger = LogFactory.getLog(TestcontainersLifecycleBeanPostProcessor.class);
@ -74,9 +77,14 @@ class TestcontainersLifecycleBeanPostProcessor implements DestructionAwareBeanPo
this.startup = startup;
}
@Override
public void onApplicationEvent(BeforeTestcontainersPropertySuppliedEvent event) {
initializeContainers();
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (this.beanFactory.isConfigurationFrozen() && this.containersInitialized.compareAndSet(false, true)) {
if (this.beanFactory.isConfigurationFrozen()) {
initializeContainers();
}
if (bean instanceof Startable startableBean) {
@ -121,15 +129,17 @@ class TestcontainersLifecycleBeanPostProcessor implements DestructionAwareBeanPo
}
private void initializeContainers() {
logger.trace("Initializing containers");
List<String> beanNames = List.of(this.beanFactory.getBeanNamesForType(ContainerState.class, false, false));
List<Object> beans = getBeans(beanNames);
if (beans != null) {
logger.trace(LogMessage.format("Initialized containers %s", beanNames));
}
else {
logger.trace(LogMessage.format("Failed to initialize containers %s", beanNames));
this.containersInitialized.set(false);
if (this.containersInitialized.compareAndSet(false, true)) {
logger.trace("Initializing containers");
List<String> beanNames = List.of(this.beanFactory.getBeanNamesForType(ContainerState.class, false, false));
List<Object> beans = getBeans(beanNames);
if (beans != null) {
logger.trace(LogMessage.format("Initialized containers %s", beanNames));
}
else {
logger.trace(LogMessage.format("Failed to initialize containers %s", beanNames));
this.containersInitialized.set(false);
}
}
}

View File

@ -0,0 +1,47 @@
/*
* Copyright 2012-2024 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.properties;
import java.util.function.Supplier;
import org.springframework.context.ApplicationEvent;
/**
* Event published just before the {@link Supplier value supplier} of a
* {@link TestcontainersPropertySource} property is called.
*
* @author Phillip Webb
* @since 3.2.2
*/
public class BeforeTestcontainersPropertySuppliedEvent extends ApplicationEvent {
private final String propertyName;
BeforeTestcontainersPropertySuppliedEvent(TestcontainersPropertySource source, String propertyName) {
super(source);
this.propertyName = propertyName;
}
/**
* Return the name of the property about to be supplied.
* @return the propertyName the property name
*/
public String getPropertyName() {
return this.propertyName;
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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,10 +19,20 @@ package org.springframework.boot.testcontainers.properties;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.function.Supplier;
import org.testcontainers.containers.Container;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.env.Environment;
@ -44,6 +54,8 @@ public class TestcontainersPropertySource extends EnumerablePropertySource<Map<S
private final DynamicPropertyRegistry registry;
private final Set<ApplicationEventPublisher> eventPublishers = new CopyOnWriteArraySet<>();
TestcontainersPropertySource() {
this(Collections.synchronizedMap(new LinkedHashMap<>()));
}
@ -57,10 +69,20 @@ public class TestcontainersPropertySource extends EnumerablePropertySource<Map<S
};
}
private void addEventPublisher(ApplicationEventPublisher eventPublisher) {
this.eventPublishers.add(eventPublisher);
}
@Override
public Object getProperty(String name) {
Supplier<Object> valueSupplier = this.source.get(name);
return (valueSupplier != null) ? valueSupplier.get() : null;
return (valueSupplier != null) ? getProperty(name, valueSupplier) : null;
}
private Object getProperty(String name, Supplier<Object> valueSupplier) {
BeforeTestcontainersPropertySuppliedEvent event = new BeforeTestcontainersPropertySuppliedEvent(this, name);
this.eventPublishers.forEach((eventPublisher) -> eventPublisher.publishEvent(event));
return valueSupplier.get();
}
@Override
@ -74,20 +96,73 @@ public class TestcontainersPropertySource extends EnumerablePropertySource<Map<S
}
public static DynamicPropertyRegistry attach(Environment environment) {
Assert.state(environment instanceof ConfigurableEnvironment,
"TestcontainersPropertySource can only be attached to a ConfigurableEnvironment");
return attach((ConfigurableEnvironment) environment);
return attach(environment, null);
}
private static DynamicPropertyRegistry attach(ConfigurableEnvironment environment) {
static DynamicPropertyRegistry attach(ConfigurableApplicationContext applicationContext) {
return attach(applicationContext.getEnvironment(), applicationContext, null);
}
public static DynamicPropertyRegistry attach(Environment environment, BeanDefinitionRegistry registry) {
return attach(environment, null, registry);
}
private static DynamicPropertyRegistry attach(Environment environment, ApplicationEventPublisher eventPublisher,
BeanDefinitionRegistry registry) {
Assert.state(environment instanceof ConfigurableEnvironment,
"TestcontainersPropertySource can only be attached to a ConfigurableEnvironment");
TestcontainersPropertySource propertySource = getOrAdd((ConfigurableEnvironment) environment);
if (eventPublisher != null) {
propertySource.addEventPublisher(eventPublisher);
}
else if (registry != null) {
registry.registerBeanDefinition(EventPublisherRegistrar.NAME, new RootBeanDefinition(
EventPublisherRegistrar.class, () -> new EventPublisherRegistrar(environment)));
}
return propertySource.registry;
}
static TestcontainersPropertySource getOrAdd(ConfigurableEnvironment environment) {
PropertySource<?> propertySource = environment.getPropertySources().get(NAME);
if (propertySource == null) {
environment.getPropertySources().addFirst(new TestcontainersPropertySource());
return attach(environment);
return getOrAdd(environment);
}
Assert.state(propertySource instanceof TestcontainersPropertySource,
"Incorrect DynamicValuesPropertySource type registered");
return ((TestcontainersPropertySource) propertySource).registry;
return ((TestcontainersPropertySource) propertySource);
}
/**
* {@link BeanFactoryPostProcessor} to register the {@link ApplicationEventPublisher}
* to the {@link TestcontainersPropertySource}. This class is a
* {@link BeanFactoryPostProcessor} so that it is initialized as early as possible.
*/
private static class EventPublisherRegistrar implements BeanFactoryPostProcessor, ApplicationEventPublisherAware {
static final String NAME = EventPublisherRegistrar.class.getName();
private final Environment environment;
private ApplicationEventPublisher eventPublisher;
EventPublisherRegistrar(Environment environment) {
this.environment = environment;
}
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
if (this.eventPublisher != null) {
TestcontainersPropertySource.getOrAdd((ConfigurableEnvironment) this.environment)
.addEventPublisher(this.eventPublisher);
}
}
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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,9 +16,12 @@
package org.springframework.boot.testcontainers.properties;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.test.context.DynamicPropertyRegistry;
/**
@ -28,6 +31,8 @@ import org.springframework.test.context.DynamicPropertyRegistry;
* @author Phillip Webb
* @since 3.1.0
*/
@AutoConfiguration
@Order(Ordered.HIGHEST_PRECEDENCE)
@ConditionalOnClass(DynamicPropertyRegistry.class)
public class TestcontainersPropertySourceAutoConfiguration {
@ -35,8 +40,8 @@ public class TestcontainersPropertySourceAutoConfiguration {
}
@Bean
DynamicPropertyRegistry dynamicPropertyRegistry(ConfigurableEnvironment environment) {
return TestcontainersPropertySource.attach(environment);
static DynamicPropertyRegistry dynamicPropertyRegistry(ConfigurableApplicationContext applicationContext) {
return TestcontainersPropertySource.attach(applicationContext);
}
}

View File

@ -0,0 +1,102 @@
/*
* Copyright 2012-2024 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.lifecycle;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.context.ImportTestcontainers;
import org.springframework.boot.testcontainers.lifecycle.TestcontainersImportWithPropertiesInjectedIntoLoadTimeWeaverAwareBeanIntegrationTests.Containers;
import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable;
import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
import org.springframework.context.annotation.Bean;
import org.springframework.context.weaving.LoadTimeWeaverAware;
import org.springframework.instrument.classloading.LoadTimeWeaver;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.junit.jupiter.SpringExtension;
/**
* @author Phillip Webb
*/
@ExtendWith(SpringExtension.class)
@DirtiesContext
@DisabledIfDockerUnavailable
@ImportTestcontainers(Containers.class)
class TestcontainersImportWithPropertiesInjectedIntoLoadTimeWeaverAwareBeanIntegrationTests {
// gh-38913
@Test
void starts() {
}
@TestConfiguration
@EnableConfigurationProperties(MockDataSourceProperties.class)
static class Config {
@Bean
MockEntityManager mockEntityManager(MockDataSourceProperties properties) {
return new MockEntityManager();
}
}
static class MockEntityManager implements LoadTimeWeaverAware {
@Override
public void setLoadTimeWeaver(LoadTimeWeaver loadTimeWeaver) {
}
}
@ConfigurationProperties("spring.datasource")
public static class MockDataSourceProperties {
private String url;
public String getUrl() {
return this.url;
}
public void setUrl(String url) {
this.url = url;
}
}
static class Containers {
@Container
static PostgreSQLContainer<?> container = new PostgreSQLContainer<>(DockerImageNames.postgresql());
@DynamicPropertySource
static void setConnectionProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", container::getJdbcUrl);
registry.add("spring.datasource.password", container::getPassword);
registry.add("spring.datasource.username", container::getUsername);
}
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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,9 @@
package org.springframework.boot.testcontainers.properties;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
@ -25,6 +28,7 @@ import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.boot.testcontainers.lifecycle.TestcontainersLifecycleApplicationContextInitializer;
import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable;
import org.springframework.boot.testsupport.testcontainers.RedisContainer;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@ -46,11 +50,16 @@ class TestcontainersPropertySourceAutoConfigurationTests {
@Test
void containerBeanMethodContributesProperties() {
this.contextRunner.withUserConfiguration(ContainerAndPropertiesConfiguration.class).run((context) -> {
TestBean testBean = context.getBean(TestBean.class);
RedisContainer redisContainer = context.getBean(RedisContainer.class);
assertThat(testBean.getUsingPort()).isEqualTo(redisContainer.getFirstMappedPort());
});
List<ApplicationEvent> events = new ArrayList<>();
this.contextRunner.withUserConfiguration(ContainerAndPropertiesConfiguration.class)
.withInitializer((context) -> context.addApplicationListener(events::add))
.run((context) -> {
TestBean testBean = context.getBean(TestBean.class);
RedisContainer redisContainer = context.getBean(RedisContainer.class);
assertThat(testBean.getUsingPort()).isEqualTo(redisContainer.getFirstMappedPort());
assertThat(events.stream().filter(BeforeTestcontainersPropertySuppliedEvent.class::isInstance))
.hasSize(1);
});
}
@Configuration(proxyBeanMethods = false)

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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,10 +16,15 @@
package org.springframework.boot.testcontainers.properties;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.env.PropertySource;
import org.springframework.mock.env.MockEnvironment;
@ -101,4 +106,20 @@ class TestcontainersPropertySourceTests {
assertThat(p1).isSameAs(p2);
}
@Test
void getPropertyPublishesEvent() {
try (GenericApplicationContext applicationContext = new GenericApplicationContext()) {
List<ApplicationEvent> events = new ArrayList<>();
applicationContext.addApplicationListener(events::add);
DynamicPropertyRegistry registry = TestcontainersPropertySource.attach(applicationContext.getEnvironment(),
(BeanDefinitionRegistry) applicationContext.getBeanFactory());
applicationContext.refresh();
registry.add("test", () -> "spring");
assertThat(applicationContext.getEnvironment().containsProperty("test")).isTrue();
assertThat(events.isEmpty());
assertThat(applicationContext.getEnvironment().getProperty("test")).isEqualTo("spring");
assertThat(events.stream().filter(BeforeTestcontainersPropertySuppliedEvent.class::isInstance)).hasSize(1);
}
}
}