Add boot42-rc1-checklist module: Spring Boot 4.2.0-RC1 upgrade checklist run against RC1 and 4.1.2

This commit is contained in:
2026-10-22 18:34:34 +00:00
parent 2996628ff1
commit 917d39fb65
25 changed files with 631 additions and 0 deletions
@@ -0,0 +1,15 @@
import org.springframework.web.client.RestTemplate;
import org.springframework.boot.restclient.RestTemplateBuilder;
/**
* Not part of the build. scripts/compile-broken.sh compiles it with -Xlint:deprecation and
* commits the compiler's own message. RestTemplate and the Boot builder around it are
* deprecated in Spring Framework 7.1 / Spring Boot 4.2.
*/
public class UsesRestTemplate {
public String fetch(RestTemplateBuilder builder) {
RestTemplate rest = builder.build();
return rest.getForObject("http://localhost:8080/ping", String.class);
}
}
@@ -0,0 +1,12 @@
package com.ankurm.rc1;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* A non-String property with a placeholder default. On 4.1.x the literal text
* {@code ${demo.port:8080}} cannot be converted to int, so the context fails to start.
*/
@ConfigurationProperties("demoport")
public record DemoPort(@DefaultValue("${demoport.number:8080}") int number) {
}
@@ -0,0 +1,16 @@
package com.ankurm.rc1;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* Constructor-bound properties whose defaults contain placeholders.
*
* <p>Spring Boot 4.2.0-M2 made {@code @DefaultValue} resolve property placeholders during
* binding. On 4.1.x the same annotation value is taken literally.
*/
@ConfigurationProperties("demo")
public record DemoProperties(
@DefaultValue("${demo.region:eu}-primary") String zone,
@DefaultValue("static-value") String plain) {
}
@@ -0,0 +1,14 @@
package com.ankurm.rc1;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class Rc1ChecklistApplication {
public static void main(String[] args) {
SpringApplication.run(Rc1ChecklistApplication.class, args);
}
}
@@ -0,0 +1,68 @@
package com.ankurm.rc1;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
class DefaultValuePlaceholderTest {
@EnableConfigurationProperties(DemoProperties.class)
static class StringConfig {
}
@EnableConfigurationProperties(DemoPort.class)
static class PortConfig {
}
@Test
void placeholderInDefaultValue() {
Transcript t = new Transcript("01-default-value-placeholders");
new ApplicationContextRunner().withUserConfiguration(StringConfig.class).run(ctx -> {
DemoProperties p = ctx.getBean(DemoProperties.class);
t.line("no demo.* properties set: zone = " + p.zone() + " plain = " + p.plain());
if (Transcript.is42()) {
assertThat(p.zone()).isEqualTo("eu-primary");
}
else {
assertThat(p.zone()).isEqualTo("${demo.region:eu}-primary");
}
});
new ApplicationContextRunner().withUserConfiguration(StringConfig.class)
.withPropertyValues("demo.region=ap")
.run(ctx -> {
DemoProperties p = ctx.getBean(DemoProperties.class);
t.line("demo.region=ap: zone = " + p.zone());
if (Transcript.is42()) {
assertThat(p.zone()).isEqualTo("ap-primary");
}
else {
assertThat(p.zone()).isEqualTo("${demo.region:eu}-primary");
}
});
new ApplicationContextRunner().withUserConfiguration(PortConfig.class).run(ctx -> {
if (Transcript.is42()) {
assertThat(ctx).hasNotFailed();
t.line("int property, placeholder default: number = " + ctx.getBean(DemoPort.class).number());
assertThat(ctx.getBean(DemoPort.class).number()).isEqualTo(8080);
}
else {
assertThat(ctx).hasFailed();
Throwable root = rootCause(ctx.getStartupFailure());
t.line("int property, placeholder default: context FAILED to start");
t.line("root cause: " + root.getClass().getName());
t.line(" " + root.getMessage());
}
});
t.write();
}
private static Throwable rootCause(Throwable ex) {
Throwable t = ex;
while (t.getCause() != null) {
t = t.getCause();
}
return t;
}
}
@@ -0,0 +1,98 @@
package com.ankurm.rc1;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.BindContext;
import org.springframework.boot.context.properties.bind.BindHandler;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.ConfigurationPropertyName;
import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
import org.springframework.boot.kafka.autoconfigure.KafkaProperties;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Binds the new Kafka admin properties and records which of the five keys the binder actually
* consumed. On 4.2 all five bind. On 4.1.2 Boot does not complain about the unknown keys; they
* are simply never read, which is the failure mode worth knowing about.
*/
class KafkaAdminBindingTest {
private static final Map<String, String> PROPS = Map.of(
"spring.kafka.template.admin.client-id", "template-admin",
"spring.kafka.template.admin.fail-fast", "true",
"spring.kafka.listener.admin.client-id", "listener-admin",
"spring.kafka.listener.admin.fail-fast", "true",
"spring.kafka.listener.await-async-results-on-stop", "true");
@Test
void adminPropertiesBind() throws Exception {
Transcript t = new Transcript("02-kafka-admin-binding");
Set<String> consumed = new TreeSet<>();
BindHandler recorder = new BindHandler() {
@Override
public Object onSuccess(ConfigurationPropertyName name, Bindable<?> target, BindContext context,
Object result) {
if (context.getConfigurationProperty() != null) {
consumed.add(name.toString());
}
return result;
}
};
Binder binder = new Binder(new MapConfigurationPropertySource(PROPS));
KafkaProperties kp = binder.bind("spring.kafka", Bindable.of(KafkaProperties.class), recorder)
.orElseGet(KafkaProperties::new);
t.line("properties supplied: " + PROPS.size());
for (String key : new TreeSet<>(PROPS.keySet())) {
t.line((consumed.contains(key.substring("spring.kafka.".length())) || consumed.contains(key) ? "bound " : "IGNORED ")
+ key);
}
long bound = PROPS.keySet().stream()
.filter(k -> consumed.contains(k.substring("spring.kafka.".length())) || consumed.contains(k))
.count();
t.line("bound = " + bound + " of " + PROPS.size());
Object listener = call(kp, "getListener");
Object template = call(kp, "getTemplate");
t.line("listener.isAwaitAsyncResultsOnStop() = " + callOrAbsent(listener, "isAwaitAsyncResultsOnStop"));
t.line("template.getAdmin() = " + callOrAbsent(template, "getAdmin"));
if (Transcript.is42()) {
assertThat(bound).isEqualTo(5);
Object tplAdmin = call(template, "getAdmin");
Object lsnAdmin = call(listener, "getAdmin");
t.line("template admin client-id = " + call(tplAdmin, "getClientId"));
t.line("template admin fail-fast = " + call(tplAdmin, "isFailFast"));
t.line("listener admin client-id = " + call(lsnAdmin, "getClientId"));
t.line("listener admin fail-fast = " + call(lsnAdmin, "isFailFast"));
assertThat(call(tplAdmin, "getClientId")).isEqualTo("template-admin");
assertThat(call(lsnAdmin, "isFailFast")).isEqualTo(true);
}
else {
assertThat(bound).isZero();
}
t.write();
}
private static Object call(Object target, String method) throws Exception {
Method m = target.getClass().getMethod(method);
return m.invoke(target);
}
private static String callOrAbsent(Object target, String method) {
try {
return String.valueOf(call(target, method));
}
catch (NoSuchMethodException ex) {
return "no such method";
}
catch (Exception ex) {
return ex.toString();
}
}
}
@@ -0,0 +1,44 @@
package com.ankurm.rc1;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.ldap.autoconfigure.embedded.EmbeddedLdapAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 4.2.0-M2: spring.ldap.embedded.ssl.enabled=true without spring.ldap.embedded.ssl.bundle now
* fails at startup instead of quietly starting a plain-text listener.
*/
class LdapEmbeddedSslTest {
@Test
void sslWithoutBundle() {
Transcript t = new Transcript("03-ldap-embedded-ssl-without-bundle");
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(EmbeddedLdapAutoConfiguration.class))
.withPropertyValues("spring.ldap.embedded.base-dn=dc=example,dc=org",
"spring.ldap.embedded.port=0",
"spring.ldap.embedded.ssl.enabled=true")
.run(ctx -> {
if (Transcript.is42()) {
assertThat(ctx).hasFailed();
Throwable root = ctx.getStartupFailure();
while (root.getCause() != null) {
root = root.getCause();
}
t.line("spring.ldap.embedded.ssl.enabled=true, no bundle: context FAILED to start");
t.line(root.getClass().getName());
t.line(root.getMessage());
}
else {
assertThat(ctx).hasNotFailed();
t.line("spring.ldap.embedded.ssl.enabled=true, no bundle: context STARTED");
t.line("embedded server beans: "
+ ctx.getBeanNamesForType(com.unboundid.ldap.listener.InMemoryDirectoryServer.class).length);
}
});
t.write();
}
}
@@ -0,0 +1,54 @@
package com.ankurm.rc1;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.context.annotation.Configuration;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Starts an empty application with the property keys 4.2 relocated or deprecated and captures
* what spring-boot-properties-migrator prints. The keys come from the 4.2.0-M2 configuration
* changelog.
*/
@ExtendWith(OutputCaptureExtension.class)
class MigratorTest {
@Configuration(proxyBeanMethods = false)
static class Empty {
}
@Test
void migratorReport(CapturedOutput output) {
Transcript t = new Transcript("04-properties-migrator-report");
SpringApplication app = new SpringApplication(Empty.class);
app.setWebApplicationType(WebApplicationType.NONE);
app.setDefaultProperties(java.util.Map.of(
"server.tomcat.use-relative-redirects", "false",
"server.tomcat.redirect-context-root", "false",
"server.tomcat.additional-tld-skip-patterns", "*.jar",
"spring.mvc.pathmatch.matching-strategy", "path_pattern_parser",
"management.metrics.observations.ignored-meters", "http.server.requests"));
app.run().close();
boolean any = false;
for (String line : output.getAll().split("\n")) {
String s = line.strip();
if (s.startsWith("Property source") || s.startsWith("Key:") || s.startsWith("Reason:")
|| s.startsWith("Replacement:") || s.contains("configuration keys")
|| s.contains("have been renamed") || s.contains("deprecated") && s.contains("configuration")
|| s.contains("no longer supported") || s.contains("been removed")) {
t.line(s);
any = true;
}
}
if (Transcript.is42()) {
assertThat(output.getAll()).contains("server.tomcat.servlet.use-relative-redirects");
assertThat(any).isTrue();
}
t.write();
}
}
@@ -0,0 +1,45 @@
package com.ankurm.rc1;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* Collects lines and writes output/NN-name.txt (or NN-name-on-4.1.2.txt when run against the
* 4.1 baseline). The test asserts the same values it writes, so every figure quoted in the
* article is an assertion that fails the build when it stops being true.
*/
final class Transcript {
static final String LABEL = System.getProperty("boot.label", "4.2.0-RC1");
static boolean is42() {
return LABEL.startsWith("4.2");
}
private final List<String> lines = new ArrayList<>();
private final Path file;
Transcript(String name) {
String suffix = is42() ? "" : "-on-" + LABEL;
this.file = Path.of("output", name + suffix + ".txt");
line("# " + name + " (Spring Boot " + LABEL + ", JDK " + Runtime.version().feature() + ")");
}
Transcript line(String s) {
lines.add(s);
return this;
}
void write() {
try {
Files.createDirectories(file.getParent());
Files.write(file, lines);
}
catch (IOException ex) {
throw new IllegalStateException(ex);
}
}
}