Compare commits

..

1 Commits

Author SHA1 Message Date
Phillip Webb c7ec1fa734 Release v4.0.0-RC2 2025-11-06 11:24:02 -08:00
361 changed files with 3475 additions and 5451 deletions

View File

@ -126,7 +126,7 @@ jobs:
- name: Publish to SDKMAN! - name: Publish to SDKMAN!
uses: ./.github/actions/publish-to-sdkman uses: ./.github/actions/publish-to-sdkman
with: with:
make-default: true make-default: false
sdkman-consumer-key: ${{ secrets.SDKMAN_CONSUMER_KEY }} sdkman-consumer-key: ${{ secrets.SDKMAN_CONSUMER_KEY }}
sdkman-consumer-token: ${{ secrets.SDKMAN_CONSUMER_TOKEN }} sdkman-consumer-token: ${{ secrets.SDKMAN_CONSUMER_TOKEN }}
spring-boot-version: ${{ needs.build-and-stage-release.outputs.version }} spring-boot-version: ${{ needs.build-and-stage-release.outputs.version }}

1848
antora/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,20 @@
{ {
"scripts": { "scripts": {
"antora": "node npm/antora.js" "antora": "node npm/antora.js",
"postinstall": "patch-package"
}, },
"dependencies": { "dependencies": {
"@antora/cli": "3.2.0-alpha.10", "@antora/cli": "3.2.0-alpha.10",
"@antora/site-generator": "3.2.0-alpha.10", "@antora/site-generator": "3.2.0-alpha.4",
"@antora/atlas-extension": "1.0.0-alpha.5", "@antora/atlas-extension": "1.0.0-alpha.5",
"@springio/antora-extensions": "1.14.7", "@springio/antora-extensions": "1.14.7",
"@springio/antora-xref-extension": "1.0.0-alpha.5", "@springio/antora-xref-extension": "1.0.0-alpha.4",
"@springio/antora-zip-contents-collector-extension": "1.0.0-alpha.10", "@springio/antora-zip-contents-collector-extension": "1.0.0-alpha.8",
"@asciidoctor/tabs": "1.0.0-beta.6", "@asciidoctor/tabs": "1.0.0-beta.6",
"@springio/asciidoctor-extensions": "1.0.0-alpha.17" "@springio/asciidoctor-extensions": "1.0.0-alpha.17",
"patch-package": "^8.0.1"
}, },
"config": { "config": {
"ui-bundle-url": "https://github.com/spring-io/antora-ui-spring/releases/download/v0.4.25/ui-bundle.zip" "ui-bundle-url": "https://github.com/spring-io/antora-ui-spring/releases/download/v0.4.18/ui-bundle.zip"
} }
} }

View File

@ -0,0 +1,285 @@
diff --git a/node_modules/@vscode/gulp-vinyl-zip/lib/src/index.js b/node_modules/@vscode/gulp-vinyl-zip/lib/src/index.js
index 17d902d..0448dec 100644
--- a/node_modules/@vscode/gulp-vinyl-zip/lib/src/index.js
+++ b/node_modules/@vscode/gulp-vinyl-zip/lib/src/index.js
@@ -1,135 +1,157 @@
-'use strict';
-
-var fs = require('fs');
-var constants = fs.constants;
-var yauzl = require('yauzl');
-var File = require('../vinyl-zip');
-var queue = require('queue');
-var through = require('through');
-var map = require('through2').obj;
-
-function modeFromEntry(entry) {
- var attr = entry.externalFileAttributes >> 16 || 33188;
-
- // The following constants are not available on all platforms:
- // 448 = constants.S_IRWXU, 56 = constants.S_IRWXG, 7 = constants.S_IRWXO
- return [448, 56, 7]
- .map(function (mask) { return attr & mask; })
- .reduce(function (a, b) { return a + b; }, attr & constants.S_IFMT);
+'use strict'
+
+// This is fork of vinyl-zip with the following updates:
+// - unzipFile has an additional `.on('error'` handler
+// - toStream has an additional `zip.on('error'` handler
+
+const fs = require('fs')
+const constants = fs.constants
+const yauzl = require('yauzl')
+const File = require('vinyl')
+const queue = require('queue')
+const through = require('through')
+const map = require('through2').obj
+
+function modeFromEntry (entry) {
+ const attr = entry.externalFileAttributes >> 16 || 33188
+ return [448, 56, 7]
+ .map(function (mask) {
+ return attr & mask
+ })
+ .reduce(function (a, b) {
+ return a + b
+ }, attr & constants.S_IFMT)
}
-function mtimeFromEntry(entry) {
- return yauzl.dosDateTimeToDate(entry.lastModFileDate, entry.lastModFileTime);
+function mtimeFromEntry (entry) {
+ return yauzl.dosDateTimeToDate(entry.lastModFileDate, entry.lastModFileTime)
}
-function toStream(zip) {
- var result = through();
- var q = queue();
- var didErr = false;
-
- q.on('error', function (err) {
- didErr = true;
- result.emit('error', err);
- });
-
- zip.on('entry', function (entry) {
- if (didErr) { return; }
-
- var stat = new fs.Stats();
- stat.mode = modeFromEntry(entry);
- stat.mtime = mtimeFromEntry(entry);
-
- // directories
- if (/\/$/.test(entry.fileName)) {
- stat.mode = (stat.mode & ~constants.S_IFMT) | constants.S_IFDIR;
- }
-
- var file = {
- path: entry.fileName,
- stat: stat
- };
-
- if (stat.isFile()) {
- stat.size = entry.uncompressedSize;
- if (entry.uncompressedSize === 0) {
- file.contents = Buffer.alloc(0);
- result.emit('data', new File(file));
- } else {
- q.push(function (cb) {
- zip.openReadStream(entry, function (err, readStream) {
- if (err) { return cb(err); }
- file.contents = readStream;
- result.emit('data', new File(file));
- cb();
- });
- });
-
- q.start();
- }
- } else if (stat.isSymbolicLink()) {
- stat.size = entry.uncompressedSize;
- q.push(function (cb) {
- zip.openReadStream(entry, function (err, readStream) {
- if (err) { return cb(err); }
- file.symlink = '';
- readStream.on('data', function (c) { file.symlink += c; });
- readStream.on('error', cb);
- readStream.on('end', function () {
- result.emit('data', new File(file));
- cb();
- });
- });
- });
-
- q.start();
- } else if (stat.isDirectory()) {
- result.emit('data', new File(file));
- } else {
- result.emit('data', new File(file));
- }
- });
-
- zip.on('end', function () {
- if (didErr) {
- return;
- }
-
- if (q.length === 0) {
- result.end();
- } else {
- q.on('end', function () {
- result.end();
- });
- }
- });
-
- return result;
+function toStream (zip) {
+ const result = through()
+ const q = queue()
+ let didErr = false
+
+ q.on('error', function (err) {
+ didErr = true
+ result.emit('error', err)
+ })
+
+ zip.on('error', function (err) {
+ didErr = true
+ result.emit('error', err)
+ })
+
+ zip.on('entry', function (entry) {
+ if (didErr) {
+ return
+ }
+
+ const stat = new fs.Stats()
+ stat.mode = modeFromEntry(entry)
+ stat.mtime = mtimeFromEntry(entry)
+
+ // directories
+ if (/\/$/.test(entry.fileName)) {
+ stat.mode = (stat.mode & ~constants.S_IFMT) | constants.S_IFDIR
+ }
+
+ const file = {
+ path: entry.fileName,
+ stat,
+ }
+
+ if (stat.isFile()) {
+ stat.size = entry.uncompressedSize
+ if (entry.uncompressedSize === 0) {
+ file.contents = Buffer.alloc(0)
+ result.emit('data', new File(file))
+ } else {
+ q.push(function (cb) {
+ zip.openReadStream(entry, function (err, readStream) {
+ if (err) {
+ return cb(err)
+ }
+ file.contents = readStream
+ result.emit('data', new File(file))
+ cb()
+ })
+ })
+
+ q.start()
+ }
+ } else if (stat.isSymbolicLink()) {
+ stat.size = entry.uncompressedSize
+ q.push(function (cb) {
+ zip.openReadStream(entry, function (err, readStream) {
+ if (err) {
+ return cb(err)
+ }
+ file.symlink = ''
+ readStream.on('data', function (c) {
+ file.symlink += c
+ })
+ readStream.on('error', cb)
+ readStream.on('end', function () {
+ result.emit('data', new File(file))
+ cb()
+ })
+ })
+ })
+
+ q.start()
+ } else if (stat.isDirectory()) {
+ result.emit('data', new File(file))
+ } else {
+ result.emit('data', new File(file))
+ }
+ })
+
+ zip.on('end', function () {
+ if (didErr) {
+ return
+ }
+
+ if (q.length === 0) {
+ result.end()
+ } else {
+ q.on('end', function () {
+ result.end()
+ })
+ }
+ })
+
+ return result
}
-function unzipFile(zipPath) {
- var result = through();
- yauzl.open(zipPath, function (err, zip) {
- if (err) { return result.emit('error', err); }
- toStream(zip).pipe(result);
- });
- return result;
+function unzipFile (zipPath) {
+ const result = through()
+ yauzl.open(zipPath, function (err, zip) {
+ if (err) {
+ return result.emit('error', err)
+ }
+ toStream(zip)
+ .on('error', (err) => result.emit('error', err))
+ .pipe(result)
+ })
+ return result
}
-function unzip() {
- return map(function (file, enc, next) {
- if (!file.isBuffer()) return next(new Error('Only supports buffers'));
- yauzl.fromBuffer(file.contents, (err, zip) => {
- if (err) return this.emit('error', err);
- toStream(zip)
- .on('error', next)
- .on('data', (data) => this.push(data))
- .on('end', next);
- });
- });
+function unzip () {
+ return map(function (file, enc, next) {
+ if (!file.isBuffer()) return next(new Error('Only supports buffers'))
+ yauzl.fromBuffer(file.contents, (err, zip) => {
+ if (err) return this.emit('error', err)
+ toStream(zip)
+ .on('error', next)
+ .on('data', (data) => this.push(data))
+ .on('end', next)
+ })
+ })
}
-function src(zipPath) {
- return zipPath ? unzipFile(zipPath) : unzip();
+function src (zipPath) {
+ return zipPath ? unzipFile(zipPath) : unzip()
}
-module.exports = src;
+module.exports = src

View File

@ -2,6 +2,7 @@
<settings defaultResolver="chain" /> <settings defaultResolver="chain" />
<resolvers> <resolvers>
<chain name="chain"> <chain name="chain">
<!-- NOTE: You should declare only repositories that you need here -->
<filesystem name="local" local="true" m2compatible="true"> <filesystem name="local" local="true" m2compatible="true">
<artifact pattern="${user.home}/.m2/[organisation]/[module]/[revision]/[module]-[revision].[ext]" /> <artifact pattern="${user.home}/.m2/[organisation]/[module]/[revision]/[module]-[revision].[ext]" />
<ivy pattern="${user.home}/.m2/[organisation]/[module]/[revision]/[module]-[revision].pom" /> <ivy pattern="${user.home}/.m2/[organisation]/[module]/[revision]/[module]-[revision].pom" />
@ -10,5 +11,6 @@
<!-- {spring.mavenRepositories} --> <!-- {spring.mavenRepositories} -->
</chain> </chain>
</resolvers> </resolvers>
<!-- {spring.mavenCredentials} --> <credentials host="${env.COMMERCIAL_RELEASE_REPO_URL}" username="${env.COMMERCIAL_REPO_USERNAME}" passwd="${env.COMMERCIAL_REPO_PASSWORD}"/>
<credentials host="${env.COMMERCIAL_SNAPSHOT_REPO_URL}" username="${env.COMMERCIAL_REPO_USERNAME}" passwd="${env.COMMERCIAL_REPO_PASSWORD}"/>
</ivysettings> </ivysettings>

View File

@ -23,7 +23,7 @@ apply plugin: 'io.spring.dependency-management'
// tag::dependencies[] // tag::dependencies[]
dependencies { dependencies {
implementation('org.springframework.boot:spring-boot-starter-webmvc') implementation('org.springframework.boot:spring-boot-starter-web')
providedRuntime('org.springframework.boot:spring-boot-starter-tomcat-runtime') providedRuntime('org.springframework.boot:spring-boot-tomcat-runtime')
} }
// end::dependencies[] // end::dependencies[]

View File

@ -8,6 +8,6 @@ apply(plugin = "io.spring.dependency-management")
// tag::dependencies[] // tag::dependencies[]
dependencies { dependencies {
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
providedRuntime("org.springframework.boot:spring-boot-starter-tomcat-runtime") providedRuntime("org.springframework.boot:spring-boot-tomcat-runtime")
} }
// end::dependencies[] // end::dependencies[]

View File

@ -27,7 +27,7 @@ The `assemble` task is automatically configured to depend upon the `bootWar` tas
=== Packaging Executable and Deployable Wars === Packaging Executable and Deployable Wars
A war file can be packaged such that it can be executed using `java -jar` and deployed to an external container. A war file can be packaged such that it can be executed using `java -jar` and deployed to an external container.
To do so, the embedded servlet container runtime should be added to the `providedRuntime` configuration, for example: To do so, the embedded servlet runtime should be added to the `providedRuntime` configuration, for example:
[tabs] [tabs]
====== ======
@ -45,7 +45,7 @@ include::example$packaging/war-container-dependency.gradle.kts[tags=dependencies
---- ----
====== ======
This ensures that the runtime jars are packaged in the war file's `WEB-INF/lib-provided` directory from where they will not conflict with the external container's own classes. This ensures that the runtime is packaged in the war file's `WEB-INF/lib-provided` directory from where it will not conflict with the external container's own classes.
NOTE: `providedRuntime` is preferred to Gradle's `compileOnly` configuration as, among other limitations, `compileOnly` dependencies are not on the test classpath so any web-based integration tests will fail. NOTE: `providedRuntime` is preferred to Gradle's `compileOnly` configuration as, among other limitations, `compileOnly` dependencies are not on the test classpath so any web-based integration tests will fail.

View File

@ -86,8 +86,6 @@ class WarPluginAction implements PluginApplicationAction {
.minus((developmentOnly.minus(productionRuntimeClasspath))) .minus((developmentOnly.minus(productionRuntimeClasspath)))
.minus((testAndDevelopmentOnly.minus(productionRuntimeClasspath))) .minus((testAndDevelopmentOnly.minus(productionRuntimeClasspath)))
.filter(new JarTypeFileSpec()); .filter(new JarTypeFileSpec());
Callable<FileCollection> providedClasspath = () -> providedRuntimeConfiguration(project)
.filter(new JarTypeFileSpec());
TaskProvider<ResolveMainClassName> resolveMainClassName = project.getTasks() TaskProvider<ResolveMainClassName> resolveMainClassName = project.getTasks()
.named(SpringBootPlugin.RESOLVE_MAIN_CLASS_NAME_TASK_NAME, ResolveMainClassName.class); .named(SpringBootPlugin.RESOLVE_MAIN_CLASS_NAME_TASK_NAME, ResolveMainClassName.class);
TaskProvider<BootWar> bootWarProvider = project.getTasks() TaskProvider<BootWar> bootWarProvider = project.getTasks()
@ -95,7 +93,7 @@ class WarPluginAction implements PluginApplicationAction {
bootWar.setGroup(BasePlugin.BUILD_GROUP); bootWar.setGroup(BasePlugin.BUILD_GROUP);
bootWar.setDescription("Assembles an executable war archive containing webapp" bootWar.setDescription("Assembles an executable war archive containing webapp"
+ " content, and the main classes and their dependencies."); + " content, and the main classes and their dependencies.");
bootWar.providedClasspath(providedClasspath); bootWar.providedClasspath(providedRuntimeConfiguration(project));
bootWar.setClasspath(classpath); bootWar.setClasspath(classpath);
Provider<String> manifestStartClass = project Provider<String> manifestStartClass = project
.provider(() -> (String) bootWar.getManifest().getAttributes().get("Start-Class")); .provider(() -> (String) bootWar.getManifest().getAttributes().get("Start-Class"));

View File

@ -64,7 +64,6 @@ import org.springframework.boot.loader.tools.ReachabilityMetadataProperties;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.StreamUtils; import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.util.function.ThrowingSupplier;
/** /**
* A {@link CopyAction} for creating a Spring Boot zip archive (typically a jar or war). * A {@link CopyAction} for creating a Spring Boot zip archive (typically a jar or war).
@ -326,7 +325,7 @@ class BootZipCopyAction implements CopyAction {
private void writeJarModeLibrary(String location, JarModeLibrary library) throws IOException { private void writeJarModeLibrary(String location, JarModeLibrary library) throws IOException {
String name = location + library.getName(); String name = location + library.getName();
writeEntry(name, ZipEntryContentWriter.fromInputStream(library.openStream()), false, writeEntry(name, ZipEntryContentWriter.fromInputStream(library.openStream()), false,
(entry) -> prepareStoredEntry(library::openStream, false, entry)); (entry) -> prepareStoredEntry(library.openStream(), false, entry));
if (BootZipCopyAction.this.layerResolver != null) { if (BootZipCopyAction.this.layerResolver != null) {
Layer layer = BootZipCopyAction.this.layerResolver.getLayer(library); Layer layer = BootZipCopyAction.this.layerResolver.getLayer(library);
Assert.state(this.layerIndex != null, "'layerIndex' must not be null"); Assert.state(this.layerIndex != null, "'layerIndex' must not be null");
@ -430,12 +429,12 @@ class BootZipCopyAction implements CopyAction {
} }
private void prepareStoredEntry(FileCopyDetails details, ZipArchiveEntry archiveEntry) throws IOException { private void prepareStoredEntry(FileCopyDetails details, ZipArchiveEntry archiveEntry) throws IOException {
prepareStoredEntry(details::open, BootZipCopyAction.this.requiresUnpack.isSatisfiedBy(details), prepareStoredEntry(details.open(), BootZipCopyAction.this.requiresUnpack.isSatisfiedBy(details),
archiveEntry); archiveEntry);
} }
private void prepareStoredEntry(ThrowingSupplier<InputStream> input, boolean unpack, private void prepareStoredEntry(InputStream input, boolean unpack, ZipArchiveEntry archiveEntry)
ZipArchiveEntry archiveEntry) throws IOException { throws IOException {
new StoredEntryPreparator(input, unpack).prepareStoredEntry(archiveEntry); new StoredEntryPreparator(input, unpack).prepareStoredEntry(archiveEntry);
} }
@ -565,10 +564,10 @@ class BootZipCopyAction implements CopyAction {
private long size; private long size;
StoredEntryPreparator(ThrowingSupplier<InputStream> input, boolean unpack) throws IOException { StoredEntryPreparator(InputStream inputStream, boolean unpack) throws IOException {
this.unpack = unpack; this.unpack = unpack;
try (InputStream stream = input.get()) { try (inputStream) {
load(stream); load(inputStream);
} }
} }

View File

@ -56,7 +56,7 @@ import org.springframework.boot.testsupport.gradle.testkit.GradleBuild;
*/ */
public class PluginClasspathGradleBuild extends GradleBuild { public class PluginClasspathGradleBuild extends GradleBuild {
private boolean kotlin; private boolean kotlin = false;
public PluginClasspathGradleBuild(BuildOutput buildOutput) { public PluginClasspathGradleBuild(BuildOutput buildOutput) {
super(buildOutput); super(buildOutput);

View File

@ -87,8 +87,8 @@ class SpringRepositoriesExtension {
addRepositories { } addRepositories { }
} }
def mavenRepositoriesFor(version) { def mavenRepositories(condition) {
addRepositories(version) { } if (condition) addRepositories { }
} }
def mavenRepositoriesExcludingBootGroup() { def mavenRepositoriesExcludingBootGroup() {
@ -100,13 +100,9 @@ class SpringRepositoriesExtension {
} }
private void addRepositories(action) { private void addRepositories(action) {
addRepositories(this.version, action) addCommercialRepository("release", false, "/spring-enterprise-maven-prod-local", action)
} if (this.version.endsWith("-SNAPSHOT")) {
addCommercialRepository("snapshot", true, "/spring-enterprise-maven-dev-local", action)
private void addRepositories(version, action) {
addCommercialRepositoryIfNecessary("release", false, "/spring-enterprise-maven-prod-local", action)
if (version.endsWith("-SNAPSHOT")) {
addCommercialRepositoryIfNecessary("snapshot", true, "/spring-enterprise-maven-dev-local", action)
addOssRepository("snapshot", true, "/snapshot", action) addOssRepository("snapshot", true, "/snapshot", action)
} }
} }
@ -117,7 +113,7 @@ class SpringRepositoriesExtension {
addRepository(name, snapshot, url, action) addRepository(name, snapshot, url, action)
} }
private void addCommercialRepositoryIfNecessary(id, snapshot, path, action) { private void addCommercialRepository(id, snapshot, path, action) {
if (!"commercial".equalsIgnoreCase(this.buildType)) return if (!"commercial".equalsIgnoreCase(this.buildType)) return
def name = "spring-commercial-" + id def name = "spring-commercial-" + id
def url = fromEnv("COMMERCIAL_%SREPO_URL", id, "https://usw1.packages.broadcom.com" + path) def url = fromEnv("COMMERCIAL_%SREPO_URL", id, "https://usw1.packages.broadcom.com" + path)

View File

@ -24,7 +24,6 @@ plugins {
repositories { repositories {
mavenCentral() mavenCentral()
spring.mavenRepositoriesFor("${springFrameworkVersion}")
gradlePluginPortal() gradlePluginPortal()
} }
@ -33,6 +32,10 @@ java {
targetCompatibility = 17 targetCompatibility = 17
} }
repositories {
spring.mavenRepositories("${springFrameworkVersion}".contains("-"))
}
checkstyle { checkstyle {
toolVersion = "${checkstyleToolVersion}" toolVersion = "${checkstyleToolVersion}"
} }
@ -111,10 +114,6 @@ gradlePlugin {
id = "org.springframework.boot.bom" id = "org.springframework.boot.bom"
implementationClass = "org.springframework.boot.build.bom.BomPlugin" implementationClass = "org.springframework.boot.build.bom.BomPlugin"
} }
configurationMetadataPlugin {
id = "org.springframework.boot.configuration-metadata"
implementationClass = "org.springframework.boot.build.context.properties.ConfigurationMetadataPlugin"
}
configurationPropertiesPlugin { configurationPropertiesPlugin {
id = "org.springframework.boot.configuration-properties" id = "org.springframework.boot.configuration-properties"
implementationClass = "org.springframework.boot.build.context.properties.ConfigurationPropertiesPlugin" implementationClass = "org.springframework.boot.build.context.properties.ConfigurationPropertiesPlugin"

View File

@ -230,18 +230,10 @@ class JavaConventions {
CoreJavadocOptions options = (CoreJavadocOptions) javadoc.getOptions(); CoreJavadocOptions options = (CoreJavadocOptions) javadoc.getOptions();
options.source("17"); options.source("17");
options.encoding("UTF-8"); options.encoding("UTF-8");
addValuelessOption(options, "Xdoclint:none"); options.addStringOption("Xdoclint:none", "-quiet");
addValuelessOption(options, "quiet");
if (!javadoc.getName().contains("aggregated")) {
addValuelessOption(options, "-no-fonts");
}
}); });
} }
private void addValuelessOption(CoreJavadocOptions options, String option) {
options.addMultilineMultiValueOption(option).setValue(List.of(Collections.emptyList()));
}
private void configureJavaConventions(Project project) { private void configureJavaConventions(Project project) {
if (!project.hasProperty("toolchainVersion")) { if (!project.hasProperty("toolchainVersion")) {
JavaPluginExtension javaPluginExtension = project.getExtensions().getByType(JavaPluginExtension.class); JavaPluginExtension javaPluginExtension = project.getExtensions().getByType(JavaPluginExtension.class);

View File

@ -20,9 +20,7 @@ import java.net.URI;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.function.BiFunction; import java.util.function.BiFunction;
import java.util.function.Function; import java.util.function.Function;
@ -39,8 +37,6 @@ import org.gradle.api.artifacts.repositories.MavenArtifactRepository;
*/ */
public class RepositoryTransformersExtension { public class RepositoryTransformersExtension {
private static final String CREDENTIALS_MARKER = "{spring.mavenCredentials}";
private static final String REPOSITORIES_MARKER = "{spring.mavenRepositories}"; private static final String REPOSITORIES_MARKER = "{spring.mavenRepositories}";
private static final String PLUGIN_REPOSITORIES_MARKER = "{spring.mavenPluginRepositories}"; private static final String PLUGIN_REPOSITORIES_MARKER = "{spring.mavenPluginRepositories}";
@ -64,19 +60,6 @@ public class RepositoryTransformersExtension {
return "%s<ibiblio name=\"%s\" m2compatible=\"true\" root=\"%s\" />".formatted(indent, name, url); return "%s<ibiblio name=\"%s\" m2compatible=\"true\" root=\"%s\" />".formatted(indent, name, url);
}); });
} }
if (line.contains(CREDENTIALS_MARKER)) {
Map<String, MavenCredential> hostCredentials = new LinkedHashMap<>();
getSpringRepositories().forEach((repository) -> {
if (repository.getName().startsWith("spring-commercial-")) {
String host = repository.getUrl().getHost();
hostCredentials.put(host,
new MavenCredential("${env.COMMERCIAL_REPO_USERNAME}", "${env.COMMERCIAL_REPO_PASSWORD"));
}
});
return transform(line, hostCredentials.entrySet(), (entry,
indent) -> "%s<credentials host=\"%s\" realm=\"Artifactory Realm\" username=\"%s\" passwd=\"%s\" />%n"
.formatted(indent, entry.getKey(), entry.getValue().username(), entry.getValue().password()));
}
return line; return line;
} }
@ -116,14 +99,10 @@ public class RepositoryTransformersExtension {
} }
private String transform(String line, BiFunction<MavenArtifactRepository, String, String> generator) { private String transform(String line, BiFunction<MavenArtifactRepository, String, String> generator) {
return transform(line, getSpringRepositories(), generator);
}
private <T> String transform(String line, Iterable<T> iterable, BiFunction<T, String, String> generator) {
StringBuilder result = new StringBuilder(); StringBuilder result = new StringBuilder();
String indent = getIndent(line); String indent = getIndent(line);
iterable.forEach((item) -> { getSpringRepositories().forEach((repository) -> {
String fragment = generator.apply(item, indent); String fragment = generator.apply(repository, indent);
if (fragment != null) { if (fragment != null) {
result.append(!result.isEmpty() ? "\n" : ""); result.append(!result.isEmpty() ? "\n" : "");
result.append(fragment); result.append(fragment);
@ -157,8 +136,4 @@ public class RepositoryTransformersExtension {
project.getExtensions().create("springRepositoryTransformers", RepositoryTransformersExtension.class, project); project.getExtensions().create("springRepositoryTransformers", RepositoryTransformersExtension.class, project);
} }
record MavenCredential(String username, String password) {
}
} }

View File

@ -26,7 +26,6 @@ import java.nio.file.StandardOpenOption;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
import java.util.function.Supplier; import java.util.function.Supplier;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -43,7 +42,6 @@ import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.FileCollection; import org.gradle.api.file.FileCollection;
import org.gradle.api.file.FileTree; import org.gradle.api.file.FileTree;
import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.ListProperty;
import org.gradle.api.provider.MapProperty;
import org.gradle.api.provider.Property; import org.gradle.api.provider.Property;
import org.gradle.api.provider.Provider; import org.gradle.api.provider.Provider;
import org.gradle.api.provider.SetProperty; import org.gradle.api.provider.SetProperty;
@ -74,27 +72,19 @@ import org.gradle.api.tasks.VerificationException;
*/ */
public abstract class ArchitectureCheck extends DefaultTask { public abstract class ArchitectureCheck extends DefaultTask {
static final String CONDITIONAL_ON_CLASS = "ConditionalOnClass";
static final String DEPRECATED_CONFIGURATION_PROPERTY = "DeprecatedConfigurationProperty";
private static final String CONDITIONAL_ON_CLASS_ANNOTATION = "org.springframework.boot.autoconfigure.condition.ConditionalOnClass"; private static final String CONDITIONAL_ON_CLASS_ANNOTATION = "org.springframework.boot.autoconfigure.condition.ConditionalOnClass";
private static final String DEPRECATED_CONFIGURATION_PROPERTY_ANNOTATION = "org.springframework.boot.context.properties.DeprecatedConfigurationProperty";
private FileCollection classes; private FileCollection classes;
public ArchitectureCheck() { public ArchitectureCheck() {
getOutputDirectory().convention(getProject().getLayout().getBuildDirectory().dir(getName())); getOutputDirectory().convention(getProject().getLayout().getBuildDirectory().dir(getName()));
getAnnotationClasses().convention(Map.of(CONDITIONAL_ON_CLASS, CONDITIONAL_ON_CLASS_ANNOTATION, getConditionalOnClassAnnotation().convention(CONDITIONAL_ON_CLASS_ANNOTATION);
DEPRECATED_CONFIGURATION_PROPERTY, DEPRECATED_CONFIGURATION_PROPERTY_ANNOTATION));
getRules().addAll(getProhibitObjectsRequireNonNull().convention(true) getRules().addAll(getProhibitObjectsRequireNonNull().convention(true)
.map(whenTrue(ArchitectureRules::noClassesShouldCallObjectsRequireNonNull))); .map(whenTrue(ArchitectureRules::noClassesShouldCallObjectsRequireNonNull)));
getRules().addAll(ArchitectureRules.standard()); getRules().addAll(ArchitectureRules.standard());
getRules().addAll(whenMainSources(() -> ArchitectureRules getRules().addAll(whenMainSources(() -> List
.beanMethods(annotationClassFor(CONDITIONAL_ON_CLASS, CONDITIONAL_ON_CLASS_ANNOTATION)))); .of(ArchitectureRules.allBeanMethodsShouldReturnNonPrivateType(), ArchitectureRules
getRules().addAll(whenMainSources(() -> ArchitectureRules.configurationProperties( .allBeanMethodsShouldNotHaveConditionalOnClassAnnotation(getConditionalOnClassAnnotation().get()))));
annotationClassFor(DEPRECATED_CONFIGURATION_PROPERTY, DEPRECATED_CONFIGURATION_PROPERTY_ANNOTATION))));
getRules().addAll(and(getNullMarkedEnabled(), isMainSourceSet()).map(whenTrue(() -> Collections.singletonList( getRules().addAll(and(getNullMarkedEnabled(), isMainSourceSet()).map(whenTrue(() -> Collections.singletonList(
ArchitectureRules.packagesShouldBeAnnotatedWithNullMarked(getNullMarkedIgnoredPackages().get()))))); ArchitectureRules.packagesShouldBeAnnotatedWithNullMarked(getNullMarkedIgnoredPackages().get())))));
getRuleDescriptions().set(getRules().map(this::asDescriptions)); getRuleDescriptions().set(getRules().map(this::asDescriptions));
@ -120,10 +110,6 @@ public abstract class ArchitectureCheck extends DefaultTask {
return rules.stream().map(ArchRule::getDescription).toList(); return rules.stream().map(ArchRule::getDescription).toList();
} }
private String annotationClassFor(String name, String defaultValue) {
return getAnnotationClasses().get().getOrDefault(name, defaultValue);
}
@TaskAction @TaskAction
void checkArchitecture() throws Exception { void checkArchitecture() throws Exception {
withCompileClasspath(() -> { withCompileClasspath(() -> {
@ -223,7 +209,7 @@ public abstract class ArchitectureCheck extends DefaultTask {
@Internal @Internal
abstract SetProperty<String> getNullMarkedIgnoredPackages(); abstract SetProperty<String> getNullMarkedIgnoredPackages();
@Input @Internal
abstract MapProperty<String, String> getAnnotationClasses(); abstract Property<String> getConditionalOnClassAnnotation();
} }

View File

@ -16,17 +16,17 @@
package org.springframework.boot.build.architecture; package org.springframework.boot.build.architecture;
import java.util.Collections; import java.util.ArrayList;
import java.util.List;
import org.gradle.api.Plugin; import org.gradle.api.Plugin;
import org.gradle.api.Project; import org.gradle.api.Project;
import org.gradle.api.Task;
import org.gradle.api.plugins.JavaPlugin; import org.gradle.api.plugins.JavaPlugin;
import org.gradle.api.plugins.JavaPluginExtension; import org.gradle.api.plugins.JavaPluginExtension;
import org.gradle.api.tasks.SourceSet; import org.gradle.api.tasks.SourceSet;
import org.gradle.api.tasks.TaskProvider; import org.gradle.api.tasks.TaskProvider;
import org.gradle.api.tasks.compile.JavaCompile;
import org.gradle.language.base.plugins.LifecycleBasePlugin; import org.gradle.language.base.plugins.LifecycleBasePlugin;
import org.jetbrains.kotlin.gradle.tasks.KotlinCompileTool;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@ -46,45 +46,28 @@ public class ArchitecturePlugin implements Plugin<Project> {
private void registerTasks(Project project, ArchitectureCheckExtension extension) { private void registerTasks(Project project, ArchitectureCheckExtension extension) {
JavaPluginExtension javaPluginExtension = project.getExtensions().getByType(JavaPluginExtension.class); JavaPluginExtension javaPluginExtension = project.getExtensions().getByType(JavaPluginExtension.class);
List<TaskProvider<ArchitectureCheck>> packageTangleChecks = new ArrayList<>();
for (SourceSet sourceSet : javaPluginExtension.getSourceSets()) { for (SourceSet sourceSet : javaPluginExtension.getSourceSets()) {
registerArchitectureCheck(sourceSet, "java", project).configure((task) -> { TaskProvider<ArchitectureCheck> checkPackageTangles = project.getTasks()
task.setClasses(project.files(project.getTasks() .register("checkArchitecture" + StringUtils.capitalize(sourceSet.getName()), ArchitectureCheck.class,
.named(sourceSet.getCompileTaskName("java"), JavaCompile.class) (task) -> {
.flatMap((compile) -> compile.getDestinationDirectory()))); task.getSourceSet().set(sourceSet.getName());
task.getNullMarkedEnabled().set(extension.getNullMarked().getEnabled()); task.getCompileClasspath().from(sourceSet.getCompileClasspath());
task.getNullMarkedIgnoredPackages().set(extension.getNullMarked().getIgnoredPackages()); task.setClasses(sourceSet.getOutput().getClassesDirs());
}); task.getResourcesDirectory().set(sourceSet.getOutput().getResourcesDir());
project.getPlugins() task.dependsOn(sourceSet.getProcessResourcesTaskName());
.withId("org.jetbrains.kotlin.jvm", task.setDescription("Checks the architecture of the classes of the " + sourceSet.getName()
(kotlinPlugin) -> registerArchitectureCheck(sourceSet, "kotlin", project).configure((task) -> { + " source set.");
task.setClasses(project.files(project.getTasks() task.setGroup(LifecycleBasePlugin.VERIFICATION_GROUP);
.named(sourceSet.getCompileTaskName("kotlin"), KotlinCompileTool.class) task.getNullMarkedEnabled().set(extension.getNullMarked().getEnabled());
.flatMap((compile) -> compile.getDestinationDirectory()))); task.getNullMarkedIgnoredPackages().set(extension.getNullMarked().getIgnoredPackages());
task.getNullMarkedEnabled().set(false); });
task.getNullMarkedIgnoredPackages().set(Collections.emptySet()); packageTangleChecks.add(checkPackageTangles);
})); }
if (!packageTangleChecks.isEmpty()) {
TaskProvider<Task> checkTask = project.getTasks().named(LifecycleBasePlugin.CHECK_TASK_NAME);
checkTask.configure((check) -> check.dependsOn(packageTangleChecks));
} }
} }
private TaskProvider<ArchitectureCheck> registerArchitectureCheck(SourceSet sourceSet, String language,
Project project) {
TaskProvider<ArchitectureCheck> checkArchitecture = project.getTasks()
.register(
"checkArchitecture"
+ StringUtils.capitalize(sourceSet.getName() + StringUtils.capitalize(language)),
ArchitectureCheck.class, (task) -> {
task.getSourceSet().set(sourceSet.getName());
task.getCompileClasspath().from(sourceSet.getCompileClasspath());
task.getResourcesDirectory().set(sourceSet.getOutput().getResourcesDir());
task.dependsOn(sourceSet.getProcessResourcesTaskName());
task.setDescription("Checks the architecture of the " + language + " classes of the "
+ sourceSet.getName() + " source set.");
task.setGroup(LifecycleBasePlugin.VERIFICATION_GROUP);
});
project.getTasks()
.named(LifecycleBasePlugin.CHECK_TASK_NAME)
.configure((check) -> check.dependsOn(checkArchitecture));
return checkArchitecture;
}
} }

View File

@ -116,16 +116,7 @@ final class ArchitectureRules {
return List.copyOf(rules); return List.copyOf(rules);
} }
static List<ArchRule> beanMethods(String annotationName) { static ArchRule allBeanMethodsShouldReturnNonPrivateType() {
return List.of(allBeanMethodsShouldReturnNonPrivateType(),
allBeanMethodsShouldNotHaveConditionalOnClassAnnotation(annotationName));
}
static List<ArchRule> configurationProperties(String annotationName) {
return List.of(allDeprecatedConfigurationPropertiesShouldIncludeSince(annotationName));
}
private static ArchRule allBeanMethodsShouldReturnNonPrivateType() {
return methodsThatAreAnnotatedWith("org.springframework.context.annotation.Bean").should(check( return methodsThatAreAnnotatedWith("org.springframework.context.annotation.Bean").should(check(
"not return types declared with the %s modifier, as such types are incompatible with Spring AOT processing" "not return types declared with the %s modifier, as such types are incompatible with Spring AOT processing"
.formatted(JavaModifier.PRIVATE), .formatted(JavaModifier.PRIVATE),
@ -139,7 +130,7 @@ final class ArchitectureRules {
.allowEmptyShould(true); .allowEmptyShould(true);
} }
private static ArchRule allBeanMethodsShouldNotHaveConditionalOnClassAnnotation(String annotationName) { static ArchRule allBeanMethodsShouldNotHaveConditionalOnClassAnnotation(String annotationName) {
return methodsThatAreAnnotatedWith("org.springframework.context.annotation.Bean").should() return methodsThatAreAnnotatedWith("org.springframework.context.annotation.Bean").should()
.notBeAnnotatedWith(annotationName) .notBeAnnotatedWith(annotationName)
.because("@ConditionalOnClass on @Bean methods is ineffective - it doesn't prevent " .because("@ConditionalOnClass on @Bean methods is ineffective - it doesn't prevent "
@ -383,20 +374,6 @@ final class ArchitectureRules {
.allowEmptyShould(true); .allowEmptyShould(true);
} }
private static ArchRule allDeprecatedConfigurationPropertiesShouldIncludeSince(String annotationName) {
return methodsThatAreAnnotatedWith(annotationName)
.should(check("include a non-empty 'since' attribute", (method, events) -> {
JavaAnnotation<JavaMethod> annotation = method.getAnnotationOfType(annotationName);
Map<String, Object> properties = annotation.getProperties();
Object since = properties.get("since");
if (!(since instanceof String) || ((String) since).isEmpty()) {
addViolation(events, method, annotation.getDescription()
+ " should include a non-empty 'since' attribute of @DeprecatedConfigurationProperty");
}
}))
.allowEmptyShould(true);
}
private static ArchRule autoConfigurationClassesShouldBePublicAndFinal() { private static ArchRule autoConfigurationClassesShouldBePublicAndFinal() {
return ArchRuleDefinition.classes() return ArchRuleDefinition.classes()
.that(areRegularAutoConfiguration()) .that(areRegularAutoConfiguration())
@ -421,8 +398,6 @@ final class ArchitectureRules {
return ArchRuleDefinition.members() return ArchRuleDefinition.members()
.that() .that()
.areDeclaredInClassesThat(areRegularAutoConfiguration()) .areDeclaredInClassesThat(areRegularAutoConfiguration())
.and()
.areDeclaredInClassesThat(areNotKotlinClasses())
.and(areNotDefaultConstructors()) .and(areNotDefaultConstructors())
.and(areNotConstants()) .and(areNotConstants())
.and(dontOverridePublicMethods()) .and(dontOverridePublicMethods())
@ -442,18 +417,13 @@ final class ArchitectureRules {
} }
static DescribedPredicate<JavaClass> areRegularAutoConfiguration() { static DescribedPredicate<JavaClass> areRegularAutoConfiguration() {
return DescribedPredicate.describe("are regular @AutoConfiguration", return DescribedPredicate.describe("Regular @AutoConfiguration",
(javaClass) -> javaClass.isAnnotatedWith(AUTOCONFIGURATION_ANNOTATION) (javaClass) -> javaClass.isAnnotatedWith(AUTOCONFIGURATION_ANNOTATION)
&& !javaClass.getName().contains("TestAutoConfiguration") && !javaClass.isAnnotation()); && !javaClass.getName().contains("TestAutoConfiguration") && !javaClass.isAnnotation());
} }
static DescribedPredicate<JavaClass> areNotKotlinClasses() {
return DescribedPredicate.describe("are not Kotlin classes",
(javaClass) -> !javaClass.isAnnotatedWith("kotlin.Metadata"));
}
static DescribedPredicate<JavaClass> areTestAutoConfiguration() { static DescribedPredicate<JavaClass> areTestAutoConfiguration() {
return DescribedPredicate.describe("are test @AutoConfiguration", return DescribedPredicate.describe("Test @AutoConfiguration",
(javaClass) -> javaClass.isAnnotatedWith(AUTOCONFIGURATION_ANNOTATION) (javaClass) -> javaClass.isAnnotatedWith(AUTOCONFIGURATION_ANNOTATION)
&& javaClass.getName().contains("TestAutoConfiguration") && !javaClass.isAnnotation()); && javaClass.getName().contains("TestAutoConfiguration") && !javaClass.isAnnotation());
} }

View File

@ -31,16 +31,12 @@ import org.springframework.util.ReflectionUtils;
/** /**
* Finds all configurations from auto-configurations (either nested configurations or * Finds all configurations from auto-configurations (either nested configurations or
* imported ones) and checks that these classes don't contain public members. * imported ones) and checks that these classes don't contain public members.
* <p>
* Kotlin classes are ignored as Kotlin does not have package-private visibility and
* {@code internal} isn't a good substitute.
* *
* @author Moritz Halbritter * @author Moritz Halbritter
*/ */
class AutoConfigurationChecker { class AutoConfigurationChecker {
private final DescribedPredicate<JavaClass> isAutoConfiguration = ArchitectureRules.areRegularAutoConfiguration() private final DescribedPredicate<JavaClass> isAutoConfiguration = ArchitectureRules.areRegularAutoConfiguration();
.and(ArchitectureRules.areNotKotlinClasses());
EvaluationResult check(JavaClasses javaClasses) { EvaluationResult check(JavaClasses javaClasses) {
AutoConfigurations autoConfigurations = new AutoConfigurations(); AutoConfigurations autoConfigurations = new AutoConfigurations();

View File

@ -18,6 +18,15 @@ package org.springframework.boot.build.context.properties;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import org.gradle.api.file.FileTree; import org.gradle.api.file.FileTree;
import org.gradle.api.file.RegularFileProperty; import org.gradle.api.file.RegularFileProperty;
@ -28,8 +37,8 @@ import org.gradle.api.tasks.PathSensitivity;
import org.gradle.api.tasks.SourceTask; import org.gradle.api.tasks.SourceTask;
import org.gradle.api.tasks.TaskAction; import org.gradle.api.tasks.TaskAction;
import org.gradle.api.tasks.VerificationException; import org.gradle.api.tasks.VerificationException;
import tools.jackson.core.StreamReadFeature;
import org.springframework.boot.build.context.properties.ConfigurationPropertiesAnalyzer.Report; import tools.jackson.databind.json.JsonMapper;
/** /**
* {@link SourceTask} that checks additional Spring configuration metadata files. * {@link SourceTask} that checks additional Spring configuration metadata files.
@ -56,16 +65,98 @@ public abstract class CheckAdditionalSpringConfigurationMetadata extends SourceT
@TaskAction @TaskAction
void check() throws IOException { void check() throws IOException {
ConfigurationPropertiesAnalyzer analyzer = new ConfigurationPropertiesAnalyzer(getSource().getFiles()); Report report = createReport();
Report report = new Report(this.projectDir);
analyzer.analyzeSort(report);
analyzer.analyzeDeprecationSince(report);
File reportFile = getReportLocation().get().getAsFile(); File reportFile = getReportLocation().get().getAsFile();
report.write(reportFile); Files.write(reportFile.toPath(), report, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
if (report.hasProblems()) { if (report.hasProblems()) {
throw new VerificationException( throw new VerificationException(
"Problems found in additional Spring configuration metadata. See " + reportFile + " for details."); "Problems found in additional Spring configuration metadata. See " + reportFile + " for details.");
} }
} }
@SuppressWarnings("unchecked")
private Report createReport() {
JsonMapper jsonMapper = JsonMapper.builder().enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION).build();
Report report = new Report();
for (File file : getSource().getFiles()) {
Analysis analysis = report.analysis(this.projectDir.toPath().relativize(file.toPath()));
Map<String, Object> json = jsonMapper.readValue(file, Map.class);
check("groups", json, analysis);
check("properties", json, analysis);
check("hints", json, analysis);
}
return report;
}
@SuppressWarnings("unchecked")
private void check(String key, Map<String, Object> json, Analysis analysis) {
List<Map<String, Object>> groups = (List<Map<String, Object>>) json.getOrDefault(key, Collections.emptyList());
List<String> names = groups.stream().map((group) -> (String) group.get("name")).toList();
List<String> sortedNames = sortedCopy(names);
for (int i = 0; i < names.size(); i++) {
String actual = names.get(i);
String expected = sortedNames.get(i);
if (!actual.equals(expected)) {
analysis.problems.add("Wrong order at $." + key + "[" + i + "].name - expected '" + expected
+ "' but found '" + actual + "'");
}
}
}
private List<String> sortedCopy(Collection<String> original) {
List<String> copy = new ArrayList<>(original);
Collections.sort(copy);
return copy;
}
private static final class Report implements Iterable<String> {
private final List<Analysis> analyses = new ArrayList<>();
private Analysis analysis(Path path) {
Analysis analysis = new Analysis(path);
this.analyses.add(analysis);
return analysis;
}
private boolean hasProblems() {
for (Analysis analysis : this.analyses) {
if (!analysis.problems.isEmpty()) {
return true;
}
}
return false;
}
@Override
public Iterator<String> iterator() {
List<String> lines = new ArrayList<>();
for (Analysis analysis : this.analyses) {
lines.add(analysis.source.toString());
lines.add("");
if (analysis.problems.isEmpty()) {
lines.add("No problems found.");
}
else {
lines.addAll(analysis.problems);
}
lines.add("");
}
return lines.iterator();
}
}
private static final class Analysis {
private final List<String> problems = new ArrayList<>();
private final Path source;
private Analysis(Path source) {
this.source = source;
}
}
} }

View File

@ -1,121 +0,0 @@
/*
* Copyright 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.build.context.properties;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.gradle.api.DefaultTask;
import org.gradle.api.Task;
import org.gradle.api.file.FileCollection;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.tasks.InputFiles;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.PathSensitive;
import org.gradle.api.tasks.PathSensitivity;
import org.gradle.api.tasks.TaskAction;
import org.gradle.api.tasks.VerificationException;
/**
* {@link Task} that checks aggregated Spring configuration metadata.
*
* @author Andy Wilkinson
*/
public abstract class CheckAggregatedSpringConfigurationMetadata extends DefaultTask {
private FileCollection configurationPropertyMetadata;
@OutputFile
public abstract RegularFileProperty getReportLocation();
@InputFiles
@PathSensitive(PathSensitivity.RELATIVE)
public FileCollection getConfigurationPropertyMetadata() {
return this.configurationPropertyMetadata;
}
public void setConfigurationPropertyMetadata(FileCollection configurationPropertyMetadata) {
this.configurationPropertyMetadata = configurationPropertyMetadata;
}
@TaskAction
void check() throws IOException {
Report report = createReport();
File reportFile = getReportLocation().get().getAsFile();
Files.write(reportFile.toPath(), report, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
if (report.hasProblems()) {
throw new VerificationException(
"Problems found in aggregated Spring configuration metadata. See " + reportFile + " for details.");
}
}
private Report createReport() {
ConfigurationProperties configurationProperties = ConfigurationProperties
.fromFiles(this.configurationPropertyMetadata);
Set<String> propertyNames = configurationProperties.stream()
.map(ConfigurationProperty::getName)
.collect(Collectors.toSet());
List<ConfigurationProperty> missingReplacement = configurationProperties.stream()
.filter(ConfigurationProperty::isDeprecated)
.filter((deprecated) -> {
String replacement = deprecated.getDeprecation().replacement();
return replacement != null && !propertyNames.contains(replacement);
})
.toList();
return new Report(missingReplacement);
}
private static final class Report implements Iterable<String> {
private final List<ConfigurationProperty> propertiesWithMissingReplacement;
private Report(List<ConfigurationProperty> propertiesWithMissingReplacement) {
this.propertiesWithMissingReplacement = propertiesWithMissingReplacement;
}
private boolean hasProblems() {
return !this.propertiesWithMissingReplacement.isEmpty();
}
@Override
public Iterator<String> iterator() {
List<String> lines = new ArrayList<>();
if (this.propertiesWithMissingReplacement.isEmpty()) {
lines.add("No problems found.");
}
else {
lines.add("The following properties have a replacement that does not exist:");
lines.add("");
lines.addAll(this.propertiesWithMissingReplacement.stream()
.map((property) -> "\t" + property.getName() + " (replacement "
+ property.getDeprecation().replacement() + ")")
.toList());
}
lines.add("");
return lines.iterator();
}
}
}

View File

@ -1,78 +0,0 @@
/*
* Copyright 2012-present 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.build.context.properties;
import java.io.File;
import java.io.IOException;
import java.util.List;
import org.gradle.api.DefaultTask;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.ListProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputFile;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.PathSensitive;
import org.gradle.api.tasks.PathSensitivity;
import org.gradle.api.tasks.SourceTask;
import org.gradle.api.tasks.TaskAction;
import org.gradle.api.tasks.VerificationException;
import org.springframework.boot.build.context.properties.ConfigurationPropertiesAnalyzer.Report;
/**
* {@link SourceTask} that checks manual Spring configuration metadata files.
*
* @author Andy Wilkinson
* @author Stephane Nicoll
*/
public abstract class CheckManualSpringConfigurationMetadata extends DefaultTask {
private final File projectDir;
public CheckManualSpringConfigurationMetadata() {
this.projectDir = getProject().getProjectDir();
}
@OutputFile
public abstract RegularFileProperty getReportLocation();
@InputFile
@PathSensitive(PathSensitivity.RELATIVE)
public abstract Property<File> getMetadataLocation();
@Input
public abstract ListProperty<String> getExclusions();
@TaskAction
void check() throws IOException {
ConfigurationPropertiesAnalyzer analyzer = new ConfigurationPropertiesAnalyzer(
List.of(getMetadataLocation().get()));
Report report = new Report(this.projectDir);
analyzer.analyzeSort(report);
analyzer.analyzePropertyDescription(report, getExclusions().get());
analyzer.analyzeDeprecationSince(report);
File reportFile = getReportLocation().get().getAsFile();
report.write(reportFile);
if (report.hasProblems()) {
throw new VerificationException(
"Problems found in manual Spring configuration metadata. See " + reportFile + " for details.");
}
}
}

View File

@ -18,7 +18,13 @@ package org.springframework.boot.build.context.properties;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Map;
import org.gradle.api.DefaultTask; import org.gradle.api.DefaultTask;
import org.gradle.api.file.RegularFileProperty; import org.gradle.api.file.RegularFileProperty;
@ -31,8 +37,7 @@ import org.gradle.api.tasks.PathSensitivity;
import org.gradle.api.tasks.SourceTask; import org.gradle.api.tasks.SourceTask;
import org.gradle.api.tasks.TaskAction; import org.gradle.api.tasks.TaskAction;
import org.gradle.api.tasks.VerificationException; import org.gradle.api.tasks.VerificationException;
import tools.jackson.databind.json.JsonMapper;
import org.springframework.boot.build.context.properties.ConfigurationPropertiesAnalyzer.Report;
/** /**
* {@link SourceTask} that checks {@code spring-configuration-metadata.json} files. * {@link SourceTask} that checks {@code spring-configuration-metadata.json} files.
@ -41,10 +46,10 @@ import org.springframework.boot.build.context.properties.ConfigurationProperties
*/ */
public abstract class CheckSpringConfigurationMetadata extends DefaultTask { public abstract class CheckSpringConfigurationMetadata extends DefaultTask {
private final File projectRoot; private final Path projectRoot;
public CheckSpringConfigurationMetadata() { public CheckSpringConfigurationMetadata() {
this.projectRoot = getProject().getProjectDir(); this.projectRoot = getProject().getProjectDir().toPath();
} }
@OutputFile @OutputFile
@ -59,16 +64,86 @@ public abstract class CheckSpringConfigurationMetadata extends DefaultTask {
@TaskAction @TaskAction
void check() throws IOException { void check() throws IOException {
Report report = new Report(this.projectRoot); Report report = createReport();
ConfigurationPropertiesAnalyzer analyzer = new ConfigurationPropertiesAnalyzer(
List.of(getMetadataLocation().get().getAsFile()));
analyzer.analyzePropertyDescription(report, getExclusions().get());
File reportFile = getReportLocation().get().getAsFile(); File reportFile = getReportLocation().get().getAsFile();
report.write(reportFile); Files.write(reportFile.toPath(), report, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
if (report.hasProblems()) { if (report.hasProblems()) {
throw new VerificationException( throw new VerificationException(
"Problems found in Spring configuration metadata. See " + reportFile + " for details."); "Problems found in Spring configuration metadata. See " + reportFile + " for details.");
} }
} }
@SuppressWarnings("unchecked")
private Report createReport() {
JsonMapper jsonMapper = new JsonMapper();
File file = getMetadataLocation().get().getAsFile();
Report report = new Report(this.projectRoot.relativize(file.toPath()));
Map<String, Object> json = jsonMapper.readValue(file, Map.class);
List<Map<String, Object>> properties = (List<Map<String, Object>>) json.get("properties");
for (Map<String, Object> property : properties) {
String name = (String) property.get("name");
if (!isDeprecated(property) && !isDescribed(property) && !isExcluded(name)) {
report.propertiesWithNoDescription.add(name);
}
}
return report;
}
private boolean isExcluded(String propertyName) {
for (String exclusion : getExclusions().get()) {
if (propertyName.equals(exclusion)) {
return true;
}
if (exclusion.endsWith(".*")) {
if (propertyName.startsWith(exclusion.substring(0, exclusion.length() - 2))) {
return true;
}
}
}
return false;
}
@SuppressWarnings("unchecked")
private boolean isDeprecated(Map<String, Object> property) {
return (Map<String, Object>) property.get("deprecation") != null;
}
private boolean isDescribed(Map<String, Object> property) {
return property.get("description") != null;
}
private static final class Report implements Iterable<String> {
private final List<String> propertiesWithNoDescription = new ArrayList<>();
private final Path source;
private Report(Path source) {
this.source = source;
}
private boolean hasProblems() {
return !this.propertiesWithNoDescription.isEmpty();
}
@Override
public Iterator<String> iterator() {
List<String> lines = new ArrayList<>();
lines.add(this.source.toString());
lines.add("");
if (this.propertiesWithNoDescription.isEmpty()) {
lines.add("No problems found.");
}
else {
lines.add("The following properties have no description:");
lines.add("");
lines.addAll(this.propertiesWithNoDescription.stream().map((line) -> "\t" + line).toList());
}
lines.add("");
return lines.iterator();
}
}
} }

View File

@ -1,93 +0,0 @@
/*
* Copyright 2012-present 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.build.context.properties;
import java.io.File;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.attributes.Category;
import org.gradle.api.attributes.Usage;
import org.gradle.api.plugins.JavaPlugin;
import org.gradle.api.plugins.JavaPluginExtension;
import org.gradle.api.provider.Provider;
import org.gradle.api.tasks.SourceSet;
import org.gradle.api.tasks.TaskProvider;
import org.gradle.language.base.plugins.LifecycleBasePlugin;
import org.gradle.language.jvm.tasks.ProcessResources;
/**
* {@link Plugin} for projects that <em>only</em> define manual configuration metadata.
* When applied, the plugin registers a {@link CheckManualSpringConfigurationMetadata}
* task and configures the {@code check} task to depend upon it.
*
* @author Andy Wilkinson
* @author Stephane Nicoll
*/
public class ConfigurationMetadataPlugin implements Plugin<Project> {
private static final String CONFIGURATION_PROPERTIES_METADATA_CONFIGURATION_NAME = "configurationPropertiesMetadata";
/**
* Name of the {@link CheckAdditionalSpringConfigurationMetadata} task.
*/
public static final String CHECK_MANUAL_SPRING_CONFIGURATION_METADATA_TASK_NAME = "checkManualSpringConfigurationMetadata";
@Override
public void apply(Project project) {
project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> registerCheckAdditionalMetadataTask(project));
}
private void registerCheckAdditionalMetadataTask(Project project) {
TaskProvider<CheckManualSpringConfigurationMetadata> checkConfigurationMetadata = project.getTasks()
.register(CHECK_MANUAL_SPRING_CONFIGURATION_METADATA_TASK_NAME,
CheckManualSpringConfigurationMetadata.class);
SourceSet mainSourceSet = project.getExtensions()
.getByType(JavaPluginExtension.class)
.getSourceSets()
.getByName(SourceSet.MAIN_SOURCE_SET_NAME);
Provider<File> manualMetadataLocation = project.getTasks()
.named(mainSourceSet.getProcessResourcesTaskName(), ProcessResources.class)
.map((processResources) -> new File(processResources.getDestinationDir(),
"META-INF/spring-configuration-metadata.json"));
checkConfigurationMetadata.configure((check) -> {
check.getMetadataLocation().set(manualMetadataLocation);
check.getReportLocation()
.set(project.getLayout()
.getBuildDirectory()
.file("reports/manual-spring-configuration-metadata/check.txt"));
});
addMetadataArtifact(project, manualMetadataLocation);
project.getTasks()
.named(LifecycleBasePlugin.CHECK_TASK_NAME)
.configure((check) -> check.dependsOn(checkConfigurationMetadata));
}
private void addMetadataArtifact(Project project, Provider<File> metadataLocation) {
project.getConfigurations()
.consumable(CONFIGURATION_PROPERTIES_METADATA_CONFIGURATION_NAME, (configuration) -> {
configuration.attributes((attributes) -> {
attributes.attribute(Category.CATEGORY_ATTRIBUTE,
project.getObjects().named(Category.class, Category.DOCUMENTATION));
attributes.attribute(Usage.USAGE_ATTRIBUTE,
project.getObjects().named(Usage.class, "configuration-properties-metadata"));
});
});
project.getArtifacts().add(CONFIGURATION_PROPERTIES_METADATA_CONFIGURATION_NAME, metadataLocation);
}
}

View File

@ -1,272 +0,0 @@
/*
* Copyright 2012-present 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.build.context.properties;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import tools.jackson.core.StreamReadFeature;
import tools.jackson.databind.json.JsonMapper;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.function.SingletonSupplier;
/**
* Check configuration metadata for inconsistencies. The available checks are:
* <ul>
* <li>Metadata element should be sorted alphabetically: {@link #analyzeSort(Report)}</li>
* <li>Property must have a description:
* {@link #analyzePropertyDescription(Report, List)}</li>
* </ul>
*
* @author Stephane Nicoll
*/
class ConfigurationPropertiesAnalyzer {
private final Collection<File> sources;
private final SingletonSupplier<JsonMapper> jsonMapperSupplier;
ConfigurationPropertiesAnalyzer(Collection<File> sources) {
if (sources.isEmpty()) {
throw new IllegalArgumentException("At least one source should be provided");
}
this.sources = sources;
this.jsonMapperSupplier = SingletonSupplier
.of(() -> JsonMapper.builder().enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION).build());
}
void analyzeSort(Report report) {
for (File source : this.sources) {
report.registerAnalysis(source, analyzeSort(source));
}
}
private Analysis analyzeSort(File source) {
Map<String, Object> json = readJsonContent(source);
Analysis analysis = new Analysis("Metadata element order:");
analyzeMetadataElementsSort("groups", json, analysis);
analyzeMetadataElementsSort("properties", json, analysis);
analyzeMetadataElementsSort("hints", json, analysis);
return analysis;
}
@SuppressWarnings("unchecked")
private void analyzeMetadataElementsSort(String key, Map<String, Object> json, Analysis analysis) {
List<Map<String, Object>> groups = (List<Map<String, Object>>) json.getOrDefault(key, Collections.emptyList());
List<String> names = groups.stream().map((group) -> (String) group.get("name")).toList();
List<String> sortedNames = names.stream().sorted().toList();
for (int i = 0; i < names.size(); i++) {
String actual = names.get(i);
String expected = sortedNames.get(i);
if (!actual.equals(expected)) {
analysis.addItem("Wrong order at $." + key + "[" + i + "].name - expected '" + expected
+ "' but found '" + actual + "'");
}
}
}
void analyzePropertyDescription(Report report, List<String> exclusions) {
for (File source : this.sources) {
report.registerAnalysis(source, analyzePropertyDescription(source, exclusions));
}
}
@SuppressWarnings("unchecked")
private Analysis analyzePropertyDescription(File source, List<String> exclusions) {
Map<String, Object> json = readJsonContent(source);
Analysis analysis = new Analysis("The following properties have no description:");
List<Map<String, Object>> properties = (List<Map<String, Object>>) json.get("properties");
for (Map<String, Object> property : properties) {
String name = (String) property.get("name");
if (!isDeprecated(property) && !isDescribed(property) && !isExcluded(exclusions, name)) {
analysis.addItem(name);
}
}
return analysis;
}
private boolean isExcluded(List<String> exclusions, String propertyName) {
for (String exclusion : exclusions) {
if (propertyName.equals(exclusion)) {
return true;
}
if (exclusion.endsWith(".*")) {
if (propertyName.startsWith(exclusion.substring(0, exclusion.length() - 2))) {
return true;
}
}
}
return false;
}
private boolean isDeprecated(Map<String, Object> property) {
return property.get("deprecation") != null;
}
private boolean isDescribed(Map<String, Object> property) {
return property.get("description") != null;
}
void analyzeDeprecationSince(Report report) throws IOException {
for (File source : this.sources) {
report.registerAnalysis(source, analyzeDeprecationSince(source));
}
}
@SuppressWarnings("unchecked")
private Analysis analyzeDeprecationSince(File source) throws IOException {
Analysis analysis = new Analysis("The following properties are deprecated without a 'since' version:");
Map<String, Object> json = readJsonContent(source);
List<Map<String, Object>> properties = (List<Map<String, Object>>) json.get("properties");
properties.stream().filter((property) -> property.containsKey("deprecation")).forEach((property) -> {
Map<String, Object> deprecation = (Map<String, Object>) property.get("deprecation");
if (!deprecation.containsKey("since")) {
analysis.addItem(property.get("name").toString());
}
});
return analysis;
}
@SuppressWarnings("unchecked")
private Map<String, Object> readJsonContent(File source) {
return this.jsonMapperSupplier.obtain().readValue(source, Map.class);
}
private static <T> void writeAll(PrintWriter writer, Iterable<T> elements, Consumer<T> itemWriter) {
Iterator<T> it = elements.iterator();
while (it.hasNext()) {
itemWriter.accept(it.next());
if (it.hasNext()) {
writer.println();
}
}
}
static class Report {
private final File baseDirectory;
private final MultiValueMap<File, Analysis> analyses = new LinkedMultiValueMap<>();
Report(File baseDirectory) {
this.baseDirectory = baseDirectory;
}
void registerAnalysis(File path, Analysis analysis) {
this.analyses.add(path, analysis);
}
boolean hasProblems() {
return this.analyses.values()
.stream()
.anyMatch((candidates) -> candidates.stream().anyMatch(Analysis::hasProblems));
}
List<Analysis> getAnalyses(File source) {
return this.analyses.getOrDefault(source, Collections.emptyList());
}
/**
* Write this report to the given {@code file}.
* @param file the file to write the report to
*/
void write(File file) throws IOException {
Files.writeString(file.toPath(), createContent(), StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
}
private String createContent() {
if (this.analyses.isEmpty()) {
return "No problems found.";
}
StringWriter out = new StringWriter();
try (PrintWriter writer = new PrintWriter(out)) {
writeAll(writer, this.analyses.entrySet(), (entry) -> {
writer.println(this.baseDirectory.toPath().relativize(entry.getKey().toPath()));
boolean hasProblems = entry.getValue().stream().anyMatch(Analysis::hasProblems);
if (hasProblems) {
writeAll(writer, entry.getValue(), (analysis) -> analysis.createDetails(writer));
}
else {
writer.println("No problems found.");
}
});
}
return out.toString();
}
}
static class Analysis {
private final String header;
private final List<String> items;
Analysis(String header) {
this.header = header;
this.items = new ArrayList<>();
}
void addItem(String item) {
this.items.add(item);
}
boolean hasProblems() {
return !this.items.isEmpty();
}
List<String> getItems() {
return this.items;
}
void createDetails(PrintWriter writer) {
writer.println(this.header);
if (this.items.isEmpty()) {
writer.println("No problems found.");
}
else {
for (String item : this.items) {
writer.println("\t- " + item);
}
}
}
@Override
public String toString() {
StringWriter out = new StringWriter();
PrintWriter writer = new PrintWriter(out);
createDetails(writer);
return out.toString();
}
}
}

View File

@ -123,7 +123,7 @@ public abstract class DocumentConfigurationProperties extends DefaultTask {
private void jsonPrefixes(Config config) { private void jsonPrefixes(Config config) {
config.accept("spring.jackson"); config.accept("spring.jackson");
config.accept("spring.gson"); config.accept("spring.gson");
config.accept("spring.kotlinx.serialization.json"); config.accept("spring.kotlin.serialization");
} }
private void dataPrefixes(Config config) { private void dataPrefixes(Config config) {

View File

@ -142,7 +142,7 @@ class PluginXmlParser {
return new Iterator<>() { return new Iterator<>() {
private int index; private int index = 0;
@Override @Override
public boolean hasNext() { public boolean hasNext() {

View File

@ -19,11 +19,9 @@ package org.springframework.boot.build.architecture;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.Map; import java.util.Map;
@ -44,7 +42,6 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.boot.build.architecture.annotations.TestConditionalOnClass; import org.springframework.boot.build.architecture.annotations.TestConditionalOnClass;
import org.springframework.boot.build.architecture.annotations.TestDeprecatedConfigurationProperty;
import org.springframework.util.ClassUtils; import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import org.springframework.util.FileSystemUtils; import org.springframework.util.FileSystemUtils;
@ -343,16 +340,6 @@ class ArchitectureCheckTests {
build(gradleBuild, Task.CHECK_ARCHITECTURE_TEST); build(gradleBuild, Task.CHECK_ARCHITECTURE_TEST);
} }
@Test
void whenDeprecatedConfigurationPropertyIsMissingSinceShouldFailAndWriteReport() throws IOException {
prepareTask(Task.CHECK_ARCHITECTURE_MAIN, "configurationproperties", "annotations");
GradleBuild gradleBuild = this.gradleBuild.withDependencies(SPRING_CONTEXT)
.withDeprecatedConfigurationPropertyAnnotation(TestDeprecatedConfigurationProperty.class.getName());
buildAndFail(gradleBuild, Task.CHECK_ARCHITECTURE_MAIN,
"should include a non-empty 'since' attribute of @DeprecatedConfigurationProperty",
"DeprecatedConfigurationPropertySince.getProperty");
}
private void prepareTask(Task task, String... sourceDirectories) throws IOException { private void prepareTask(Task task, String... sourceDirectories) throws IOException {
for (String sourceDirectory : sourceDirectories) { for (String sourceDirectory : sourceDirectories) {
FileSystemUtils.copyRecursively( FileSystemUtils.copyRecursively(
@ -385,12 +372,7 @@ class ArchitectureCheckTests {
try { try {
BuildResult buildResult = gradleBuild.buildAndFail(task.toString()); BuildResult buildResult = gradleBuild.buildAndFail(task.toString());
assertThat(buildResult.taskPaths(TaskOutcome.FAILED)).as(buildResult.getOutput()).contains(":" + task); assertThat(buildResult.taskPaths(TaskOutcome.FAILED)).as(buildResult.getOutput()).contains(":" + task);
try { assertThat(task.getFailureReport(gradleBuild.getProjectDir())).contains(messages);
assertThat(task.getFailureReport(gradleBuild.getProjectDir())).contains(messages);
}
catch (NoSuchFileException ex) {
throw new AssertionError("Expected failure report not found\n" + buildResult.getOutput());
}
} }
catch (UnexpectedBuildSuccess ex) { catch (UnexpectedBuildSuccess ex) {
throw new AssertionError("Expected build to fail but it succeeded\n" + ex.getBuildResult().getOutput(), ex); throw new AssertionError("Expected build to fail but it succeeded\n" + ex.getBuildResult().getOutput(), ex);
@ -423,7 +405,7 @@ class ArchitectureCheckTests {
@Override @Override
public String toString() { public String toString() {
return "checkArchitecture" + StringUtils.capitalize(this.sourceSetName) + "Java"; return "checkArchitecture" + StringUtils.capitalize(this.sourceSetName);
} }
} }
@ -454,18 +436,9 @@ class ArchitectureCheckTests {
return this; return this;
} }
GradleBuild withConditionalOnClassAnnotation(String annotationClass) { GradleBuild withConditionalOnClassAnnotation(String annotationName) {
for (Task task : Task.values()) { for (Task task : Task.values()) {
configureTask(task, (configuration) -> configuration configureTask(task, (configuration) -> configuration.withConditionalOnClassAnnotation(annotationName));
.withAnnotation(ArchitectureCheck.CONDITIONAL_ON_CLASS, annotationClass));
}
return this;
}
GradleBuild withDeprecatedConfigurationPropertyAnnotation(String annotationClass) {
for (Task task : Task.values()) {
configureTask(task, (configuration) -> configuration
.withAnnotation(ArchitectureCheck.DEPRECATED_CONFIGURATION_PROPERTY, annotationClass));
} }
return this; return this;
} }
@ -525,18 +498,18 @@ class ArchitectureCheckTests {
for (String dependency : this.dependencies) { for (String dependency : this.dependencies) {
buildFile.append("\n implementation ").append(StringUtils.quote(dependency)); buildFile.append("\n implementation ").append(StringUtils.quote(dependency));
} }
buildFile.append("\n}\n\n"); buildFile.append("}\n");
} }
this.taskConfigurations.forEach((task, configuration) -> { this.taskConfigurations.forEach((task, configuration) -> {
buildFile.append(task).append(" {"); buildFile.append(task).append(" {");
if (configuration.conditionalOnClassAnnotation() != null) {
buildFile.append("\n conditionalOnClassAnnotation = ")
.append(StringUtils.quote(configuration.conditionalOnClassAnnotation()));
}
if (configuration.prohibitObjectsRequireNonNull() != null) { if (configuration.prohibitObjectsRequireNonNull() != null) {
buildFile.append("\n prohibitObjectsRequireNonNull = ") buildFile.append("\n prohibitObjectsRequireNonNull = ")
.append(configuration.prohibitObjectsRequireNonNull()); .append(configuration.prohibitObjectsRequireNonNull());
} }
if (configuration.annotations() != null && !configuration.annotations().isEmpty()) {
buildFile.append("\n annotationClasses = ")
.append(toGroovyMapString(configuration.annotations()));
}
buildFile.append("\n}\n"); buildFile.append("\n}\n");
}); });
NullMarkedExtension nullMarkedExtension = this.nullMarkedExtension; NullMarkedExtension nullMarkedExtension = this.nullMarkedExtension;
@ -563,13 +536,6 @@ class ArchitectureCheckTests {
.withPluginClasspath(); .withPluginClasspath();
} }
static String toGroovyMapString(Map<String, String> map) {
return map.entrySet()
.stream()
.map((entry) -> "'" + entry.getKey() + "' : '" + entry.getValue() + "'")
.collect(Collectors.joining(", ", "[", "]"));
}
private record NullMarkedExtension(Boolean enabled, Set<String> ignoredPackages) { private record NullMarkedExtension(Boolean enabled, Set<String> ignoredPackages) {
private NullMarkedExtension withEnabled(Boolean enabled) { private NullMarkedExtension withEnabled(Boolean enabled) {
@ -582,24 +548,15 @@ class ArchitectureCheckTests {
} }
private record TaskConfiguration(Boolean prohibitObjectsRequireNonNull, Map<String, String> annotations) { private record TaskConfiguration(Boolean prohibitObjectsRequireNonNull, String conditionalOnClassAnnotation) {
public TaskConfiguration { private TaskConfiguration withConditionalOnClassAnnotation(String annotationName) {
if (annotations == null) { return new TaskConfiguration(this.prohibitObjectsRequireNonNull, annotationName);
annotations = new HashMap<>();
}
} }
private TaskConfiguration withProhibitObjectsRequireNonNull(Boolean prohibitObjectsRequireNonNull) { private TaskConfiguration withProhibitObjectsRequireNonNull(Boolean prohibitObjectsRequireNonNull) {
return new TaskConfiguration(prohibitObjectsRequireNonNull, this.annotations); return new TaskConfiguration(prohibitObjectsRequireNonNull, this.conditionalOnClassAnnotation);
} }
private TaskConfiguration withAnnotation(String name, String annotationClass) {
Map<String, String> map = new HashMap<>(this.annotations);
map.put(name, annotationClass);
return new TaskConfiguration(this.prohibitObjectsRequireNonNull, map);
}
} }
} }

View File

@ -1,49 +0,0 @@
/*
* Copyright 2012-present 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.build.architecture.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* {@code @DeprecatedConfigurationProperty} analogue for architecture checks.
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TestDeprecatedConfigurationProperty {
/**
* The reason for the deprecation.
* @return the deprecation reason
*/
String reason() default "";
/**
* The field that should be used instead (if any).
* @return the replacement field
*/
String replacement() default "";
/**
* The version in which the property became deprecated.
* @return the version
*/
String since() default "";
}

View File

@ -1,35 +0,0 @@
/*
* Copyright 2012-present 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.build.architecture.configurationproperties;
import org.springframework.boot.build.architecture.annotations.TestDeprecatedConfigurationProperty;
public class DeprecatedConfigurationPropertySince {
private String property;
@TestDeprecatedConfigurationProperty(reason = "no longer used")
@Deprecated
public String getProperty() {
return this.property;
}
public void setProperty(String property) {
this.property = property;
}
}

View File

@ -1,211 +0,0 @@
/*
* Copyright 2012-present 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.build.context.properties;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.build.context.properties.ConfigurationPropertiesAnalyzer.Analysis;
import org.springframework.boot.build.context.properties.ConfigurationPropertiesAnalyzer.Report;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link ConfigurationPropertiesAnalyzer}.
*
* @author Stephane Nicoll
*/
class ConfigurationPropertiesAnalyzerTests {
@Test
void createAnalyzerWithNoSource() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new ConfigurationPropertiesAnalyzer(Collections.emptyList()))
.withMessage("At least one source should be provided");
}
@Test
void analyzeSortWithAlphabeticalOrder(@TempDir File tempDir) throws IOException {
File metadata = new File(tempDir, "metadata.json");
Files.writeString(metadata.toPath(), """
{ "properties": [
{ "name": "abc"}, {"name": "def"}, {"name": "xyz"}
]
}""");
Report report = new Report(tempDir);
ConfigurationPropertiesAnalyzer analyzer = new ConfigurationPropertiesAnalyzer(List.of(metadata));
analyzer.analyzeSort(report);
assertThat(report.hasProblems()).isFalse();
assertThat(report.getAnalyses(metadata)).singleElement()
.satisfies(((analysis) -> assertThat(analysis.getItems()).isEmpty()));
}
@Test
void analyzeSortWithViolations(@TempDir File tempDir) throws IOException {
File metadata = new File(tempDir, "metadata.json");
Files.writeString(metadata.toPath(), """
{ "properties": [
{ "name": "def"}, {"name": "abc"}, {"name": "xyz"}
]
}""");
Report report = new Report(tempDir);
ConfigurationPropertiesAnalyzer analyzer = new ConfigurationPropertiesAnalyzer(List.of(metadata));
analyzer.analyzeSort(report);
assertThat(report.hasProblems()).isTrue();
assertThat(report.getAnalyses(metadata)).singleElement()
.satisfies((analysis) -> assertThat(analysis.getItems()).containsExactly(
"Wrong order at $.properties[0].name - expected 'abc' but found 'def'",
"Wrong order at $.properties[1].name - expected 'def' but found 'abc'"));
}
@Test
void analyzePropertyDescription(@TempDir File tempDir) throws IOException {
File metadata = new File(tempDir, "metadata.json");
Files.writeString(metadata.toPath(), """
{ "properties": [
{ "name": "abc", "description": "This is abc." },
{ "name": "def", "description": "This is def." },
{ "name": "xyz", "description": "This is xyz." }
]
}""");
Report report = new Report(tempDir);
ConfigurationPropertiesAnalyzer analyzer = new ConfigurationPropertiesAnalyzer(List.of(metadata));
analyzer.analyzePropertyDescription(report, List.of());
assertThat(report.hasProblems()).isFalse();
assertThat(report.getAnalyses(metadata)).singleElement()
.satisfies(((analysis) -> assertThat(analysis.getItems()).isEmpty()));
}
@Test
void analyzePropertyDescriptionWithMissingDescription(@TempDir File tempDir) throws IOException {
File metadata = new File(tempDir, "metadata.json");
Files.writeString(metadata.toPath(), """
{ "properties": [
{ "name": "abc", "description": "This is abc." },
{ "name": "def" },
{ "name": "xyz", "description": "This is xyz." }
]
}""");
Report report = new Report(tempDir);
ConfigurationPropertiesAnalyzer analyzer = new ConfigurationPropertiesAnalyzer(List.of(metadata));
analyzer.analyzePropertyDescription(report, List.of());
assertThat(report.hasProblems()).isTrue();
assertThat(report.getAnalyses(metadata)).singleElement()
.satisfies(((analysis) -> assertThat(analysis.getItems()).containsExactly("def")));
}
@Test
void analyzeDeprecatedPropertyWithMissingSince(@TempDir File tempDir) throws IOException {
File metadata = new File(tempDir, "metadata.json");
Files.writeString(metadata.toPath(), """
{ "properties": [
{
"name": "abc",
"description": "This is abc.",
"deprecation": { "reason": "abc reason", "since": "3.0.0" }
},
{ "name": "def", "description": "This is def." },
{
"name": "xyz",
"description": "This is xyz.",
"deprecation": { "reason": "xyz reason" }
}
]
}""");
Report report = new Report(tempDir);
ConfigurationPropertiesAnalyzer analyzer = new ConfigurationPropertiesAnalyzer(List.of(metadata));
analyzer.analyzeDeprecationSince(report);
assertThat(report.hasProblems()).isTrue();
assertThat(report.getAnalyses(metadata)).singleElement()
.satisfies(((analysis) -> assertThat(analysis.getItems()).containsExactly("xyz")));
}
@Test
void writeEmptyReport(@TempDir File tempDir) throws IOException {
assertThat(writeToFile(tempDir, new Report(tempDir))).hasContent("No problems found.");
}
@Test
void writeReportWithNoProblemsFound(@TempDir File tempDir) throws IOException {
Report report = new Report(tempDir);
File first = new File(tempDir, "metadata-1.json");
report.registerAnalysis(first, new Analysis("Check for things:"));
File second = new File(tempDir, "metadata-2.json");
report.registerAnalysis(second, new Analysis("Check for other things:"));
assertThat(writeToFile(tempDir, report)).content().isEqualToIgnoringNewLines("""
metadata-1.json
No problems found.
metadata-2.json
No problems found.
""");
}
@Test
void writeReportWithOneProblem(@TempDir File tempDir) throws IOException {
Report report = new Report(tempDir);
File metadata = new File(tempDir, "metadata-1.json");
Analysis analysis = new Analysis("Check for things:");
analysis.addItem("Should not be deprecated");
report.registerAnalysis(metadata, analysis);
report.registerAnalysis(metadata, new Analysis("Check for other things:"));
assertThat(writeToFile(tempDir, report)).content().isEqualToIgnoringNewLines("""
metadata-1.json
Check for things:
- Should not be deprecated
Check for other things:
No problems found.
""");
}
@Test
void writeReportWithSeveralProblems(@TempDir File tempDir) throws IOException {
Report report = new Report(tempDir);
File metadata = new File(tempDir, "metadata-1.json");
Analysis firstAnalysis = new Analysis("Check for things:");
firstAnalysis.addItem("Should not be deprecated");
firstAnalysis.addItem("Should not be public");
report.registerAnalysis(metadata, firstAnalysis);
Analysis secondAnalysis = new Analysis("Check for other things:");
secondAnalysis.addItem("Field 'this' not expected");
report.registerAnalysis(metadata, secondAnalysis);
assertThat(writeToFile(tempDir, report)).content().isEqualToIgnoringNewLines("""
metadata-1.json
Check for things:
- Should not be deprecated
- Should not be public
Check for other things:
- Field 'this' not expected
""");
}
private File writeToFile(File directory, Report report) throws IOException {
File file = new File(directory, "report.txt");
report.write(file);
return file;
}
}

View File

@ -142,14 +142,14 @@ class SpringRepositoriesExtensionTests {
@Test @Test
void mavenRepositoriesWhenConditionMatches() { void mavenRepositoriesWhenConditionMatches() {
SpringRepositoriesExtension extension = createExtension("0.0.0-SNAPSHOT", "oss"); SpringRepositoriesExtension extension = createExtension("0.0.0-SNAPSHOT", "oss");
extension.mavenRepositoriesFor("1.2.3-SNAPSHOT"); extension.mavenRepositories(true);
assertThat(this.repositories).hasSize(1); assertThat(this.repositories).hasSize(1);
} }
@Test @Test
void mavenRepositoriesWhenConditionDoesNotMatch() { void mavenRepositoriesWhenConditionDoesNotMatch() {
SpringRepositoriesExtension extension = createExtension("0.0.0-SNAPSHOT", "oss"); SpringRepositoriesExtension extension = createExtension("0.0.0-SNAPSHOT", "oss");
extension.mavenRepositoriesFor("1.2.3"); extension.mavenRepositories(false);
assertThat(this.repositories).isEmpty(); assertThat(this.repositories).isEmpty();
} }
@ -252,7 +252,7 @@ class SpringRepositoriesExtensionTests {
void mavenRepositories(); void mavenRepositories();
void mavenRepositoriesFor(Object version); void mavenRepositories(boolean condition);
void mavenRepositoriesExcludingBootGroup(); void mavenRepositoriesExcludingBootGroup();

View File

@ -71,6 +71,7 @@ final class ApiVersions {
if (obj == null || getClass() != obj.getClass()) { if (obj == null || getClass() != obj.getClass()) {
return false; return false;
} }
ApiVersions other = (ApiVersions) obj; ApiVersions other = (ApiVersions) obj;
return Arrays.equals(this.apiVersions, other.apiVersions); return Arrays.equals(this.apiVersions, other.apiVersions);
} }

View File

@ -106,25 +106,17 @@ public class Builder {
this.log.start(request); this.log.start(request);
validateBindings(request.getBindings()); validateBindings(request.getBindings());
PullPolicy pullPolicy = request.getPullPolicy(); PullPolicy pullPolicy = request.getPullPolicy();
ImagePlatform platform = request.getImagePlatform(); ImageFetcher imageFetcher = new ImageFetcher(this.dockerConfiguration.builderRegistryAuthentication(),
boolean specifiedPlatform = request.getImagePlatform() != null; pullPolicy, request.getImagePlatform());
DockerRegistryAuthentication registryAuthentication = this.dockerConfiguration.builderRegistryAuthentication(); Image builderImage = imageFetcher.fetchImage(ImageType.BUILDER, request.getBuilder());
ImageFetcher imageFetcher = new ImageFetcher(registryAuthentication, pullPolicy);
Image builderImage = imageFetcher.fetchImage(ImageType.BUILDER, request.getBuilder(), platform);
BuilderMetadata builderMetadata = BuilderMetadata.fromImage(builderImage); BuilderMetadata builderMetadata = BuilderMetadata.fromImage(builderImage);
request = withRunImageIfNeeded(request, builderMetadata); request = withRunImageIfNeeded(request, builderMetadata);
Assert.state(request.getRunImage() != null, "'request.getRunImage()' must not be null"); Assert.state(request.getRunImage() != null, "'request.getRunImage()' must not be null");
platform = (platform != null) ? platform : ImagePlatform.from(builderImage); Image runImage = imageFetcher.fetchImage(ImageType.RUNNER, request.getRunImage());
Image runImage = imageFetcher.fetchImage(ImageType.RUNNER, request.getRunImage(), platform);
if (specifiedPlatform && runImage.getPrimaryDigest() != null) {
request = request.withRunImage(request.getRunImage().withDigest(runImage.getPrimaryDigest()));
runImage = imageFetcher.fetchImage(ImageType.RUNNER, request.getRunImage(), platform);
}
assertStackIdsMatch(runImage, builderImage); assertStackIdsMatch(runImage, builderImage);
BuildOwner buildOwner = BuildOwner.fromEnv(builderImage.getConfig().getEnv()); BuildOwner buildOwner = BuildOwner.fromEnv(builderImage.getConfig().getEnv());
BuildpackLayersMetadata buildpackLayersMetadata = BuildpackLayersMetadata.fromImage(builderImage); BuildpackLayersMetadata buildpackLayersMetadata = BuildpackLayersMetadata.fromImage(builderImage);
Buildpacks buildpacks = getBuildpacks(request, imageFetcher, platform, builderMetadata, Buildpacks buildpacks = getBuildpacks(request, imageFetcher, builderMetadata, buildpackLayersMetadata);
buildpackLayersMetadata);
EphemeralBuilder ephemeralBuilder = new EphemeralBuilder(buildOwner, builderImage, request.getName(), EphemeralBuilder ephemeralBuilder = new EphemeralBuilder(buildOwner, builderImage, request.getName(),
builderMetadata, request.getCreator(), request.getEnv(), buildpacks); builderMetadata, request.getCreator(), request.getEnv(), buildpacks);
executeLifecycle(request, ephemeralBuilder); executeLifecycle(request, ephemeralBuilder);
@ -168,9 +160,9 @@ public class Builder {
} }
} }
private Buildpacks getBuildpacks(BuildRequest request, ImageFetcher imageFetcher, ImagePlatform platform, private Buildpacks getBuildpacks(BuildRequest request, ImageFetcher imageFetcher, BuilderMetadata builderMetadata,
BuilderMetadata builderMetadata, BuildpackLayersMetadata buildpackLayersMetadata) { BuildpackLayersMetadata buildpackLayersMetadata) {
BuildpackResolverContext resolverContext = new BuilderResolverContext(imageFetcher, platform, builderMetadata, BuildpackResolverContext resolverContext = new BuilderResolverContext(imageFetcher, builderMetadata,
buildpackLayersMetadata); buildpackLayersMetadata);
return BuildpackResolvers.resolveAll(resolverContext, request.getBuildpacks()); return BuildpackResolvers.resolveAll(resolverContext, request.getBuildpacks());
} }
@ -233,74 +225,49 @@ public class Builder {
private final PullPolicy pullPolicy; private final PullPolicy pullPolicy;
ImageFetcher(@Nullable DockerRegistryAuthentication registryAuthentication, PullPolicy pullPolicy) { private @Nullable ImagePlatform defaultPlatform;
ImageFetcher(@Nullable DockerRegistryAuthentication registryAuthentication, PullPolicy pullPolicy,
@Nullable ImagePlatform platform) {
this.registryAuthentication = registryAuthentication; this.registryAuthentication = registryAuthentication;
this.pullPolicy = pullPolicy; this.pullPolicy = pullPolicy;
this.defaultPlatform = platform;
} }
Image fetchImage(ImageType type, ImageReference reference, @Nullable ImagePlatform platform) Image fetchImage(ImageType type, ImageReference reference) throws IOException {
throws IOException {
Assert.notNull(type, "'type' must not be null"); Assert.notNull(type, "'type' must not be null");
Assert.notNull(reference, "'reference' must not be null"); Assert.notNull(reference, "'reference' must not be null");
if (this.pullPolicy == PullPolicy.ALWAYS) { if (this.pullPolicy == PullPolicy.ALWAYS) {
return pullImageAndCheckForPlatformMismatch(type, reference, platform); return checkPlatformMismatch(pullImage(reference, type), reference);
} }
try { try {
Image image = Builder.this.docker.image().inspect(reference, platform); return checkPlatformMismatch(Builder.this.docker.image().inspect(reference), reference);
return checkPlatformMismatch(image, reference, platform);
} }
catch (DockerEngineException ex) { catch (DockerEngineException ex) {
if (this.pullPolicy == PullPolicy.IF_NOT_PRESENT && ex.getStatusCode() == 404) { if (this.pullPolicy == PullPolicy.IF_NOT_PRESENT && ex.getStatusCode() == 404) {
return pullImageAndCheckForPlatformMismatch(type, reference, platform); return checkPlatformMismatch(pullImage(reference, type), reference);
} }
throw ex; throw ex;
} }
} }
private Image pullImageAndCheckForPlatformMismatch(ImageType type, ImageReference reference, private Image pullImage(ImageReference reference, ImageType imageType) throws IOException {
@Nullable ImagePlatform platform) throws IOException {
try {
Image image = pullImage(reference, type, platform);
return checkPlatformMismatch(image, reference, platform);
}
catch (DockerEngineException ex) {
// Try to throw our own exception for consistent log output. Matching
// on the message is a little brittle, but it doesn't matter too much
// if it fails as the original exception is still enough to stop the build
if (platform != null && ex.getMessage() != null
&& ex.getMessage().contains("does not provide the specified platform")) {
throwAsPlatformMismatchException(type, reference, platform, ex);
}
throw ex;
}
}
private void throwAsPlatformMismatchException(ImageType type, ImageReference reference, ImagePlatform platform,
@Nullable Throwable cause) throws IOException {
try {
Image image = pullImage(reference, type, null);
throw new PlatformMismatchException(reference, platform, ImagePlatform.from(image), cause);
}
catch (DockerEngineException ex) {
}
}
private Image pullImage(ImageReference reference, ImageType imageType, @Nullable ImagePlatform platform)
throws IOException {
TotalProgressPullListener listener = new TotalProgressPullListener( TotalProgressPullListener listener = new TotalProgressPullListener(
Builder.this.log.pullingImage(reference, platform, imageType)); Builder.this.log.pullingImage(reference, this.defaultPlatform, imageType));
String authHeader = authHeader(this.registryAuthentication, reference); String authHeader = authHeader(this.registryAuthentication, reference);
Image image = Builder.this.docker.image().pull(reference, platform, listener, authHeader); Image image = Builder.this.docker.image().pull(reference, this.defaultPlatform, listener, authHeader);
Builder.this.log.pulledImage(image, imageType); Builder.this.log.pulledImage(image, imageType);
if (this.defaultPlatform == null) {
this.defaultPlatform = ImagePlatform.from(image);
}
return image; return image;
} }
private Image checkPlatformMismatch(Image image, ImageReference reference, private Image checkPlatformMismatch(Image image, ImageReference imageReference) {
@Nullable ImagePlatform requestedPlatform) { if (this.defaultPlatform != null) {
if (requestedPlatform != null) { ImagePlatform imagePlatform = ImagePlatform.from(image);
ImagePlatform actualPlatform = ImagePlatform.from(image); if (!imagePlatform.equals(this.defaultPlatform)) {
if (!actualPlatform.equals(requestedPlatform)) { throw new PlatformMismatchException(imageReference, this.defaultPlatform, imagePlatform);
throw new PlatformMismatchException(reference, requestedPlatform, actualPlatform, null);
} }
} }
return image; return image;
@ -311,9 +278,9 @@ public class Builder {
private static final class PlatformMismatchException extends RuntimeException { private static final class PlatformMismatchException extends RuntimeException {
private PlatformMismatchException(ImageReference imageReference, ImagePlatform requestedPlatform, private PlatformMismatchException(ImageReference imageReference, ImagePlatform requestedPlatform,
ImagePlatform actualPlatform, @Nullable Throwable cause) { ImagePlatform actualPlatform) {
super("Image platform mismatch detected. The configured platform '%s' is not supported by the image '%s'. Requested platform '%s' but got '%s'" super("Image platform mismatch detected. The configured platform '%s' is not supported by the image '%s'. Requested platform '%s' but got '%s'"
.formatted(requestedPlatform, imageReference, requestedPlatform, actualPlatform), cause); .formatted(requestedPlatform, imageReference, requestedPlatform, actualPlatform));
} }
} }
@ -359,16 +326,13 @@ public class Builder {
private final ImageFetcher imageFetcher; private final ImageFetcher imageFetcher;
private final ImagePlatform platform;
private final BuilderMetadata builderMetadata; private final BuilderMetadata builderMetadata;
private final BuildpackLayersMetadata buildpackLayersMetadata; private final BuildpackLayersMetadata buildpackLayersMetadata;
BuilderResolverContext(ImageFetcher imageFetcher, ImagePlatform platform, BuilderMetadata builderMetadata, BuilderResolverContext(ImageFetcher imageFetcher, BuilderMetadata builderMetadata,
BuildpackLayersMetadata buildpackLayersMetadata) { BuildpackLayersMetadata buildpackLayersMetadata) {
this.imageFetcher = imageFetcher; this.imageFetcher = imageFetcher;
this.platform = platform;
this.builderMetadata = builderMetadata; this.builderMetadata = builderMetadata;
this.buildpackLayersMetadata = buildpackLayersMetadata; this.buildpackLayersMetadata = buildpackLayersMetadata;
} }
@ -385,13 +349,13 @@ public class Builder {
@Override @Override
public Image fetchImage(ImageReference reference, ImageType imageType) throws IOException { public Image fetchImage(ImageReference reference, ImageType imageType) throws IOException {
return this.imageFetcher.fetchImage(imageType, reference, this.platform); return this.imageFetcher.fetchImage(imageType, reference);
} }
@Override @Override
public void exportImageLayers(ImageReference reference, IOBiConsumer<String, TarArchive> exports) public void exportImageLayers(ImageReference reference, IOBiConsumer<String, TarArchive> exports)
throws IOException { throws IOException {
Builder.this.docker.image().exportLayers(reference, this.platform, exports); Builder.this.docker.image().exportLayers(reference, exports);
} }
} }

View File

@ -16,7 +16,6 @@
package org.springframework.boot.buildpack.platform.docker; package org.springframework.boot.buildpack.platform.docker;
import java.util.Comparator;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@ -31,13 +30,10 @@ import org.springframework.util.Assert;
* @author Scott Frederick * @author Scott Frederick
* @since 4.0.0 * @since 4.0.0
*/ */
public final class ApiVersion implements Comparable<ApiVersion> { public final class ApiVersion {
private static final Pattern PATTERN = Pattern.compile("^v?(\\d+)\\.(\\d*)$"); private static final Pattern PATTERN = Pattern.compile("^v?(\\d+)\\.(\\d*)$");
private static final Comparator<ApiVersion> COMPARATOR = Comparator.comparing(ApiVersion::getMajor)
.thenComparing(ApiVersion::getMinor);
private final int major; private final int major;
private final int minor; private final int minor;
@ -142,9 +138,4 @@ public final class ApiVersion implements Comparable<ApiVersion> {
return new ApiVersion(major, minor); return new ApiVersion(major, minor);
} }
@Override
public int compareTo(ApiVersion other) {
return COMPARATOR.compare(this, other);
}
} }

View File

@ -62,9 +62,11 @@ public class DockerApi {
private static final List<String> FORCE_PARAMS = Collections.unmodifiableList(Arrays.asList("force", "1")); private static final List<String> FORCE_PARAMS = Collections.unmodifiableList(Arrays.asList("force", "1"));
static final ApiVersion UNKNOWN_API_VERSION = ApiVersion.of(0, 0); static final ApiVersion API_VERSION = ApiVersion.of(1, 24);
static final ApiVersion PREFERRED_API_VERSION = ApiVersion.of(1, 50); static final ApiVersion PLATFORM_API_VERSION = ApiVersion.of(1, 41);
static final ApiVersion UNKNOWN_API_VERSION = ApiVersion.of(0, 0);
static final String API_VERSION_HEADER_NAME = "API-Version"; static final String API_VERSION_HEADER_NAME = "API-Version";
@ -124,38 +126,18 @@ public class DockerApi {
return this.jsonStream; return this.jsonStream;
} }
URI buildPlatformJsonUrl(Feature feature, @Nullable ImagePlatform platform, String path) {
if (platform != null && getApiVersion().supports(feature.minimumVersion())) {
return buildUrl(feature, path, "platform", platform.toJson());
}
return buildUrl(path);
}
private URI buildUrl(String path, @Nullable Collection<?> params) { private URI buildUrl(String path, @Nullable Collection<?> params) {
return buildUrl(Feature.BASELINE, path, (params != null) ? params.toArray() : null); return buildUrl(API_VERSION, path, (params != null) ? params.toArray() : null);
} }
private URI buildUrl(String path, Object... params) { private URI buildUrl(String path, Object... params) {
return buildUrl(Feature.BASELINE, path, params); return buildUrl(API_VERSION, path, params);
} }
URI buildUrl(Feature feature, String path, Object @Nullable ... params) { private URI buildUrl(ApiVersion apiVersion, String path, Object @Nullable ... params) {
ApiVersion version = getApiVersion(); verifyApiVersion(apiVersion);
if (version.equals(UNKNOWN_API_VERSION) || (version.compareTo(PREFERRED_API_VERSION) >= 0
&& version.compareTo(feature.minimumVersion()) >= 0)) {
return buildVersionedUrl(PREFERRED_API_VERSION, path, params);
}
if (version.compareTo(feature.minimumVersion()) >= 0) {
return buildVersionedUrl(version, path, params);
}
throw new IllegalStateException(
"Docker API version must be at least %s to support this feature, but current API version is %s"
.formatted(feature.minimumVersion(), version));
}
private URI buildVersionedUrl(ApiVersion version, String path, Object @Nullable ... params) {
try { try {
URIBuilder builder = new URIBuilder("/v" + version + path); URIBuilder builder = new URIBuilder("/v" + apiVersion + path);
if (params != null) { if (params != null) {
int param = 0; int param = 0;
while (param < params.length) { while (param < params.length) {
@ -169,6 +151,13 @@ public class DockerApi {
} }
} }
private void verifyApiVersion(ApiVersion minimumVersion) {
ApiVersion actualVersion = getApiVersion();
Assert.state(actualVersion.equals(UNKNOWN_API_VERSION) || actualVersion.supports(minimumVersion),
() -> "Docker API version must be at least " + minimumVersion
+ " to support this feature, but current API version is " + actualVersion);
}
private ApiVersion getApiVersion() { private ApiVersion getApiVersion() {
ApiVersion apiVersion = this.apiVersion; ApiVersion apiVersion = this.apiVersion;
if (apiVersion == null) { if (apiVersion == null) {
@ -236,8 +225,9 @@ public class DockerApi {
UpdateListener<PullImageUpdateEvent> listener, @Nullable String registryAuth) throws IOException { UpdateListener<PullImageUpdateEvent> listener, @Nullable String registryAuth) throws IOException {
Assert.notNull(reference, "'reference' must not be null"); Assert.notNull(reference, "'reference' must not be null");
Assert.notNull(listener, "'listener' must not be null"); Assert.notNull(listener, "'listener' must not be null");
URI createUri = (platform != null) ? buildUrl(Feature.PLATFORM_IMAGE_PULL, "/images/create", "fromImage", URI createUri = (platform != null)
reference, "platform", platform) : buildUrl("/images/create", "fromImage", reference); ? buildUrl(PLATFORM_API_VERSION, "/images/create", "fromImage", reference, "platform", platform)
: buildUrl("/images/create", "fromImage", reference);
DigestCaptureUpdateListener digestCapture = new DigestCaptureUpdateListener(); DigestCaptureUpdateListener digestCapture = new DigestCaptureUpdateListener();
listener.onStart(); listener.onStart();
try { try {
@ -247,7 +237,7 @@ public class DockerApi {
listener.onUpdate(event); listener.onUpdate(event);
}); });
} }
return inspect(reference, platform); return inspect((platform != null) ? PLATFORM_API_VERSION : API_VERSION, reference);
} }
finally { finally {
listener.onFinish(); listener.onFinish();
@ -319,24 +309,9 @@ public class DockerApi {
*/ */
public void exportLayers(ImageReference reference, IOBiConsumer<String, TarArchive> exports) public void exportLayers(ImageReference reference, IOBiConsumer<String, TarArchive> exports)
throws IOException { throws IOException {
exportLayers(reference, null, exports);
}
/**
* Export the layers of an image as {@link TarArchive TarArchives}.
* @param reference the reference to export
* @param platform the platform (os/architecture/variant) of the image to export.
* Ignored on older versions of Docker.
* @param exports a consumer to receive the layers (contents can only be accessed
* during the callback)
* @throws IOException on IO error
* @since 3.4.12
*/
public void exportLayers(ImageReference reference, @Nullable ImagePlatform platform,
IOBiConsumer<String, TarArchive> exports) throws IOException {
Assert.notNull(reference, "'reference' must not be null"); Assert.notNull(reference, "'reference' must not be null");
Assert.notNull(exports, "'exports' must not be null"); Assert.notNull(exports, "'exports' must not be null");
URI uri = buildPlatformJsonUrl(Feature.PLATFORM_IMAGE_EXPORT, platform, "/images/" + reference + "/get"); URI uri = buildUrl("/images/" + reference + "/get");
try (Response response = http().get(uri)) { try (Response response = http().get(uri)) {
try (ExportedImageTar exportedImageTar = new ExportedImageTar(reference, response.getContent())) { try (ExportedImageTar exportedImageTar = new ExportedImageTar(reference, response.getContent())) {
exportedImageTar.exportLayers(exports); exportedImageTar.exportLayers(exports);
@ -364,25 +339,13 @@ public class DockerApi {
* @throws IOException on IO error * @throws IOException on IO error
*/ */
public Image inspect(ImageReference reference) throws IOException { public Image inspect(ImageReference reference) throws IOException {
return inspect(reference, null); return inspect(API_VERSION, reference);
} }
/** private Image inspect(ApiVersion apiVersion, ImageReference reference) throws IOException {
* Inspect an image.
* @param reference the image reference
* @param platform the platform (os/architecture/variant) of the image to inspect.
* Ignored on older versions of Docker.
* @return the image from the local repository
* @throws IOException on IO error
* @since 3.4.12
*/
public Image inspect(ImageReference reference, @Nullable ImagePlatform platform) throws IOException {
// The Docker documentation is incomplete but platform parameters
// are supported since 1.49 (see https://github.com/moby/moby/pull/49586)
Assert.notNull(reference, "'reference' must not be null"); Assert.notNull(reference, "'reference' must not be null");
URI inspectUrl = buildPlatformJsonUrl(Feature.PLATFORM_IMAGE_INSPECT, platform, URI imageUri = buildUrl(apiVersion, "/images/" + reference + "/json");
"/images/" + reference + "/json"); try (Response response = http().get(imageUri)) {
try (Response response = http().get(inspectUrl)) {
return Image.of(response.getContent()); return Image.of(response.getContent());
} }
} }
@ -430,7 +393,7 @@ public class DockerApi {
private ContainerReference createContainer(ContainerConfig config, @Nullable ImagePlatform platform) private ContainerReference createContainer(ContainerConfig config, @Nullable ImagePlatform platform)
throws IOException { throws IOException {
URI createUri = (platform != null) URI createUri = (platform != null)
? buildUrl(Feature.PLATFORM_CONTAINER_CREATE, "/containers/create", "platform", platform) ? buildUrl(PLATFORM_API_VERSION, "/containers/create", "platform", platform)
: buildUrl("/containers/create"); : buildUrl("/containers/create");
try (Response response = http().post(createUri, "application/json", config::writeTo)) { try (Response response = http().post(createUri, "application/json", config::writeTo)) {
return ContainerReference return ContainerReference
@ -635,28 +598,4 @@ public class DockerApi {
} }
enum Feature {
BASELINE(ApiVersion.of(1, 24)),
PLATFORM_IMAGE_PULL(ApiVersion.of(1, 41)),
PLATFORM_CONTAINER_CREATE(ApiVersion.of(1, 41)),
PLATFORM_IMAGE_INSPECT(ApiVersion.of(1, 49)),
PLATFORM_IMAGE_EXPORT(ApiVersion.of(1, 48));
private final ApiVersion minimumVersion;
Feature(ApiVersion minimumVersion) {
this.minimumVersion = minimumVersion;
}
ApiVersion minimumVersion() {
return this.minimumVersion;
}
}
} }

View File

@ -19,12 +19,9 @@ package org.springframework.boot.buildpack.platform.docker;
import java.util.Objects; import java.util.Objects;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
import tools.jackson.databind.node.ObjectNode;
import org.springframework.boot.buildpack.platform.docker.type.Image; import org.springframework.boot.buildpack.platform.docker.type.Image;
import org.springframework.boot.buildpack.platform.json.SharedJsonMapper;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/** /**
* A platform specification for a Docker image. * A platform specification for a Docker image.
@ -105,20 +102,4 @@ public class ImagePlatform {
return new ImagePlatform(image.getOs(), image.getArchitecture(), image.getVariant()); return new ImagePlatform(image.getOs(), image.getArchitecture(), image.getVariant());
} }
/**
* Return a JSON-encoded representation of this platform.
* @return the JSON string
*/
public String toJson() {
ObjectNode json = SharedJsonMapper.get().createObjectNode();
json.put("os", this.os);
if (StringUtils.hasText(this.architecture)) {
json.put("architecture", this.architecture);
}
if (StringUtils.hasText(this.variant)) {
json.put("variant", this.variant);
}
return json.toString();
}
} }

View File

@ -22,13 +22,11 @@ import java.lang.invoke.MethodHandles;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Objects;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
import tools.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import org.springframework.boot.buildpack.platform.json.MappedObject; import org.springframework.boot.buildpack.platform.json.MappedObject;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
/** /**
@ -54,8 +52,6 @@ public class Image extends MappedObject {
private final @Nullable String created; private final @Nullable String created;
private final @Nullable Descriptor descriptor;
Image(JsonNode node) { Image(JsonNode node) {
super(node, MethodHandles.lookup()); super(node, MethodHandles.lookup());
this.digests = childrenAt("/RepoDigests", JsonNode::asString); this.digests = childrenAt("/RepoDigests", JsonNode::asString);
@ -65,9 +61,6 @@ public class Image extends MappedObject {
this.architecture = valueAt("/Architecture", String.class); this.architecture = valueAt("/Architecture", String.class);
this.variant = valueAt("/Variant", String.class); this.variant = valueAt("/Variant", String.class);
this.created = valueAt("/Created", String.class); this.created = valueAt("/Created", String.class);
JsonNode descriptorNode = getNode().path("Descriptor");
this.descriptor = (descriptorNode.isMissingNode() || descriptorNode.isNull()) ? null
: new Descriptor(descriptorNode);
} }
private List<LayerId> extractLayers(String @Nullable [] layers) { private List<LayerId> extractLayers(String @Nullable [] layers) {
@ -133,35 +126,6 @@ public class Image extends MappedObject {
return this.created; return this.created;
} }
/**
* Return the descriptor for this image as reported by Docker Engine inspect.
* @return the image descriptor or {@code null}
*/
public @Nullable Descriptor getDescriptor() {
return this.descriptor;
}
/**
* Return the primary digest of the image or {@code null}. Checks the
* {@code Descriptor.digest} first, falling back to {@code RepoDigest}.
* @return the primary digest or {@code null}
* @since 3.4.12
*/
public @Nullable String getPrimaryDigest() {
if (this.descriptor != null && StringUtils.hasText(this.descriptor.getDigest())) {
return this.descriptor.getDigest();
}
if (!CollectionUtils.isEmpty(this.digests)) {
try {
String digest = this.digests.get(0);
return (digest != null) ? ImageReference.of(digest).getDigest() : null;
}
catch (RuntimeException ex) {
}
}
return null;
}
/** /**
* Create a new {@link Image} instance from the specified JSON content. * Create a new {@link Image} instance from the specified JSON content.
* @param content the JSON content * @param content the JSON content
@ -172,24 +136,4 @@ public class Image extends MappedObject {
return of(content, Image::new); return of(content, Image::new);
} }
/**
* Descriptor details as reported in the {@code Docker inspect} response.
*
* @since 3.4.12
*/
public final class Descriptor extends MappedObject {
private final String digest;
Descriptor(JsonNode node) {
super(node, MethodHandles.lookup());
this.digest = Objects.requireNonNull(valueAt("/digest", String.class));
}
public String getDigest() {
return this.digest;
}
}
} }

View File

@ -78,9 +78,6 @@ class BuilderTests {
private static final ImageReference BASE_CNB = ImageReference.of("docker.io/cloudfoundry/run:base-cnb"); private static final ImageReference BASE_CNB = ImageReference.of("docker.io/cloudfoundry/run:base-cnb");
private static final ImageReference PLATFORM_CNB = ImageReference
.of("docker.io/cloudfoundry/run@sha256:fb5ecb90a42b2067a859aab23fc1f5e9d9c2589d07ba285608879e7baa415aad");
@Test @Test
@SuppressWarnings("NullAway") // Test null check @SuppressWarnings("NullAway") // Test null check
void createWhenLogIsNullThrowsException() { void createWhenLogIsNullThrowsException() {
@ -281,8 +278,8 @@ class BuilderTests {
.willAnswer(withPulledImage(builderImage)); .willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(BASE_CNB), eq(ImagePlatform.from(builderImage)), any(), isNull())) given(docker.image().pull(eq(BASE_CNB), eq(ImagePlatform.from(builderImage)), any(), isNull()))
.willAnswer(withPulledImage(runImage)); .willAnswer(withPulledImage(runImage));
given(docker.image().inspect(eq(DEFAULT_BUILDER), any())).willReturn(builderImage); given(docker.image().inspect(eq(DEFAULT_BUILDER))).willReturn(builderImage);
given(docker.image().inspect(eq(BASE_CNB), any())).willReturn(runImage); given(docker.image().inspect(eq(BASE_CNB))).willReturn(runImage);
Builder builder = new Builder(BuildLog.to(out), docker, null); Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest().withPullPolicy(PullPolicy.NEVER); BuildRequest request = getTestRequest().withPullPolicy(PullPolicy.NEVER);
builder.build(request); builder.build(request);
@ -294,7 +291,7 @@ class BuilderTests {
assertThat(tag).isNotNull(); assertThat(tag).isNotNull();
then(docker.image()).should().remove(tag, true); then(docker.image()).should().remove(tag, true);
then(docker.image()).should(never()).pull(any(), any(), any()); then(docker.image()).should(never()).pull(any(), any(), any());
then(docker.image()).should(times(2)).inspect(any(), any()); then(docker.image()).should(times(2)).inspect(any());
} }
@Test @Test
@ -333,11 +330,11 @@ class BuilderTests {
.willAnswer(withPulledImage(builderImage)); .willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(BASE_CNB), eq(ImagePlatform.from(builderImage)), any(), isNull())) given(docker.image().pull(eq(BASE_CNB), eq(ImagePlatform.from(builderImage)), any(), isNull()))
.willAnswer(withPulledImage(runImage)); .willAnswer(withPulledImage(runImage));
given(docker.image().inspect(eq(DEFAULT_BUILDER), any())) given(docker.image().inspect(eq(DEFAULT_BUILDER)))
.willThrow(new TestDockerEngineException("docker://localhost/", new URI("example"), 404, "NOT FOUND", null, .willThrow(new TestDockerEngineException("docker://localhost/", new URI("example"), 404, "NOT FOUND", null,
null, null)) null, null))
.willReturn(builderImage); .willReturn(builderImage);
given(docker.image().inspect(eq(BASE_CNB), any())) given(docker.image().inspect(eq(BASE_CNB)))
.willThrow(new TestDockerEngineException("docker://localhost/", new URI("example"), 404, "NOT FOUND", null, .willThrow(new TestDockerEngineException("docker://localhost/", new URI("example"), 404, "NOT FOUND", null,
null, null)) null, null))
.willReturn(runImage); .willReturn(runImage);
@ -351,7 +348,7 @@ class BuilderTests {
ImageReference tag = archive.getValue().getTag(); ImageReference tag = archive.getValue().getTag();
assertThat(tag).isNotNull(); assertThat(tag).isNotNull();
then(docker.image()).should().remove(tag, true); then(docker.image()).should().remove(tag, true);
then(docker.image()).should(times(2)).inspect(any(), any()); then(docker.image()).should(times(2)).inspect(any());
then(docker.image()).should(times(2)).pull(any(), any(), any(), isNull()); then(docker.image()).should(times(2)).pull(any(), any(), any(), isNull());
} }
@ -428,8 +425,6 @@ class BuilderTests {
given(docker.image().pull(eq(DEFAULT_BUILDER), eq(platform), any(), isNull())) given(docker.image().pull(eq(DEFAULT_BUILDER), eq(platform), any(), isNull()))
.willAnswer(withPulledImage(builderImage)); .willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(BASE_CNB), eq(platform), any(), isNull())).willAnswer(withPulledImage(runImage)); given(docker.image().pull(eq(BASE_CNB), eq(platform), any(), isNull())).willAnswer(withPulledImage(runImage));
given(docker.image().pull(eq(PLATFORM_CNB), eq(platform), any(), isNull()))
.willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker, null); Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest().withImagePlatform("linux/arm64/v1"); BuildRequest request = getTestRequest().withImagePlatform("linux/arm64/v1");
builder.build(request); builder.build(request);
@ -438,7 +433,6 @@ class BuilderTests {
ArgumentCaptor<ImageArchive> archive = ArgumentCaptor.forClass(ImageArchive.class); ArgumentCaptor<ImageArchive> archive = ArgumentCaptor.forClass(ImageArchive.class);
then(docker.image()).should().pull(eq(DEFAULT_BUILDER), eq(platform), any(), isNull()); then(docker.image()).should().pull(eq(DEFAULT_BUILDER), eq(platform), any(), isNull());
then(docker.image()).should().pull(eq(BASE_CNB), eq(platform), any(), isNull()); then(docker.image()).should().pull(eq(BASE_CNB), eq(platform), any(), isNull());
then(docker.image()).should().pull(eq(PLATFORM_CNB), eq(platform), any(), isNull());
then(docker.image()).should().load(archive.capture(), any()); then(docker.image()).should().load(archive.capture(), any());
ImageReference tag = archive.getValue().getTag(); ImageReference tag = archive.getValue().getTag();
assertThat(tag).isNotNull(); assertThat(tag).isNotNull();

View File

@ -111,17 +111,6 @@ class ApiVersionTests {
assertThat(v12a).isEqualTo(v12a).isEqualTo(v12b).isNotEqualTo(v13); assertThat(v12a).isEqualTo(v12a).isEqualTo(v12b).isNotEqualTo(v13);
} }
@Test
void compareTo() {
assertThat(ApiVersion.of(0, 0).compareTo(ApiVersion.of(0, 0))).isZero();
assertThat(ApiVersion.of(0, 1).compareTo(ApiVersion.of(0, 1))).isZero();
assertThat(ApiVersion.of(1, 0).compareTo(ApiVersion.of(1, 0))).isZero();
assertThat(ApiVersion.of(0, 0).compareTo(ApiVersion.of(0, 1))).isLessThan(0);
assertThat(ApiVersion.of(0, 1).compareTo(ApiVersion.of(0, 0))).isGreaterThan(0);
assertThat(ApiVersion.of(1, 0).compareTo(ApiVersion.of(0, 1))).isGreaterThan(0);
assertThat(ApiVersion.of(0, 1).compareTo(ApiVersion.of(1, 0))).isLessThan(0);
}
private boolean supports(String v1, String v2) { private boolean supports(String v1, String v2) {
return ApiVersion.parse(v1).supports(ApiVersion.parse(v2)); return ApiVersion.parse(v1).supports(ApiVersion.parse(v2));
} }

View File

@ -23,8 +23,6 @@ import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays; import java.util.Arrays;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
@ -43,7 +41,6 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.buildpack.platform.docker.DockerApi.ContainerApi; import org.springframework.boot.buildpack.platform.docker.DockerApi.ContainerApi;
import org.springframework.boot.buildpack.platform.docker.DockerApi.Feature;
import org.springframework.boot.buildpack.platform.docker.DockerApi.ImageApi; import org.springframework.boot.buildpack.platform.docker.DockerApi.ImageApi;
import org.springframework.boot.buildpack.platform.docker.DockerApi.SystemApi; import org.springframework.boot.buildpack.platform.docker.DockerApi.SystemApi;
import org.springframework.boot.buildpack.platform.docker.DockerApi.VolumeApi; import org.springframework.boot.buildpack.platform.docker.DockerApi.VolumeApi;
@ -89,21 +86,22 @@ import static org.mockito.Mockito.times;
@ExtendWith({ MockitoExtension.class, OutputCaptureExtension.class }) @ExtendWith({ MockitoExtension.class, OutputCaptureExtension.class })
class DockerApiTests { class DockerApiTests {
private static final String API_URL = "/v" + DockerApi.PREFERRED_API_VERSION; private static final String API_URL = "/v" + DockerApi.API_VERSION;
private static final String PLATFORM_API_URL = "/v" + DockerApi.PLATFORM_API_VERSION;
public static final String PING_URL = "/_ping"; public static final String PING_URL = "/_ping";
private static final String IMAGES_URL = API_URL + "/images"; private static final String IMAGES_URL = API_URL + "/images";
private static final String PLATFORM_IMAGES_URL = PLATFORM_API_URL + "/images";
private static final String CONTAINERS_URL = API_URL + "/containers"; private static final String CONTAINERS_URL = API_URL + "/containers";
private static final String PLATFORM_CONTAINERS_URL = PLATFORM_API_URL + "/containers";
private static final String VOLUMES_URL = API_URL + "/volumes"; private static final String VOLUMES_URL = API_URL + "/volumes";
private static final ImagePlatform LINUX_ARM64_PLATFORM = ImagePlatform.of("linux/arm64/v1");
private static final String ENCODED_LINUX_ARM64_PLATFORM_JSON = URLEncoder.encode(LINUX_ARM64_PLATFORM.toJson(),
StandardCharsets.UTF_8);
@Mock @Mock
@SuppressWarnings("NullAway.Init") @SuppressWarnings("NullAway.Init")
private HttpTransport http; private HttpTransport http;
@ -170,52 +168,6 @@ class DockerApiTests {
assertThat(api).isNotNull(); assertThat(api).isNotNull();
} }
@Test
void buildUrlWhenUnknownVersionUsesPreferredVersion() throws Exception {
setVersion("0.0");
assertThat(this.dockerApi.buildUrl(Feature.BASELINE, "/test"))
.isEqualTo(URI.create("/v" + DockerApi.PREFERRED_API_VERSION + "/test"));
}
@Test
void buildUrlWhenVersionIsGreaterThanPreferredUsesPreferred() throws Exception {
setVersion("1000.0");
assertThat(this.dockerApi.buildUrl(Feature.BASELINE, "/test"))
.isEqualTo(URI.create("/v" + DockerApi.PREFERRED_API_VERSION + "/test"));
}
@Test
void buildUrlWhenVersionIsEqualToPreferredUsesPreferred() throws Exception {
setVersion(DockerApi.PREFERRED_API_VERSION.toString());
assertThat(this.dockerApi.buildUrl(Feature.BASELINE, "/test"))
.isEqualTo(URI.create("/v" + DockerApi.PREFERRED_API_VERSION + "/test"));
}
@Test
void buildUrlWhenVersionIsLessThanPreferredAndGreaterThanMinimumUsesVersionVersion() throws Exception {
setVersion("1.48");
assertThat(this.dockerApi.buildUrl(Feature.BASELINE, "/test")).isEqualTo(URI.create("/v1.48/test"));
}
@Test
void buildUrlWhenVersionIsLessThanPreferredAndEqualToMinimumUsesVersionVersion() throws Exception {
setVersion(Feature.BASELINE.minimumVersion().toString());
assertThat(this.dockerApi.buildUrl(Feature.BASELINE, "/test")).isEqualTo(URI.create("/v1.24/test"));
}
@Test
void buildUrlWhenVersionIsLessThanMinimumThrowsException() throws Exception {
setVersion("1.23");
assertThatIllegalStateException().isThrownBy(() -> this.dockerApi.buildUrl(Feature.BASELINE, "/test"))
.withMessage("Docker API version must be at least 1.24 "
+ "to support this feature, but current API version is 1.23");
}
private void setVersion(String version) throws IOException, URISyntaxException {
given(http().head(eq(new URI(PING_URL))))
.willReturn(responseWithHeaders(new BasicHeader(DockerApi.API_VERSION_HEADER_NAME, version)));
}
@Nested @Nested
class ImageDockerApiTests { class ImageDockerApiTests {
@ -290,14 +242,15 @@ class DockerApiTests {
@Test @Test
void pullWithPlatformPullsImageAndProducesEvents() throws Exception { void pullWithPlatformPullsImageAndProducesEvents() throws Exception {
ImageReference reference = ImageReference.of("gcr.io/paketo-buildpacks/builder:base"); ImageReference reference = ImageReference.of("gcr.io/paketo-buildpacks/builder:base");
URI createUri = new URI( ImagePlatform platform = ImagePlatform.of("linux/arm64/v1");
"/v1.49/images/create?fromImage=gcr.io%2Fpaketo-buildpacks%2Fbuilder%3Abase&platform=linux%2Farm64%2Fv1"); URI createUri = new URI(PLATFORM_IMAGES_URL
URI imageUri = new URI("/v1.49/images/gcr.io/paketo-buildpacks/builder:base/json?platform=" + "/create?fromImage=gcr.io%2Fpaketo-buildpacks%2Fbuilder%3Abase&platform=linux%2Farm64%2Fv1");
+ ENCODED_LINUX_ARM64_PLATFORM_JSON); URI imageUri = new URI(PLATFORM_IMAGES_URL + "/gcr.io/paketo-buildpacks/builder:base/json");
setVersion("1.49"); given(http().head(eq(new URI(PING_URL))))
.willReturn(responseWithHeaders(new BasicHeader(DockerApi.API_VERSION_HEADER_NAME, "1.41")));
given(http().post(eq(createUri), isNull())).willReturn(responseOf("pull-stream.json")); given(http().post(eq(createUri), isNull())).willReturn(responseOf("pull-stream.json"));
given(http().get(imageUri)).willReturn(responseOf("type/image.json")); given(http().get(imageUri)).willReturn(responseOf("type/image.json"));
Image image = this.api.pull(reference, LINUX_ARM64_PLATFORM, this.pullListener); Image image = this.api.pull(reference, platform, this.pullListener);
assertThat(image.getLayers()).hasSize(46); assertThat(image.getLayers()).hasSize(46);
InOrder ordered = inOrder(this.pullListener); InOrder ordered = inOrder(this.pullListener);
ordered.verify(this.pullListener).onStart(); ordered.verify(this.pullListener).onStart();
@ -309,7 +262,8 @@ class DockerApiTests {
void pullWithPlatformAndInsufficientApiVersionThrowsException() throws Exception { void pullWithPlatformAndInsufficientApiVersionThrowsException() throws Exception {
ImageReference reference = ImageReference.of("gcr.io/paketo-buildpacks/builder:base"); ImageReference reference = ImageReference.of("gcr.io/paketo-buildpacks/builder:base");
ImagePlatform platform = ImagePlatform.of("linux/arm64/v1"); ImagePlatform platform = ImagePlatform.of("linux/arm64/v1");
setVersion("1.24"); given(http().head(eq(new URI(PING_URL)))).willReturn(
responseWithHeaders(new BasicHeader(DockerApi.API_VERSION_HEADER_NAME, DockerApi.API_VERSION)));
assertThatIllegalStateException().isThrownBy(() -> this.api.pull(reference, platform, this.pullListener)) assertThatIllegalStateException().isThrownBy(() -> this.api.pull(reference, platform, this.pullListener))
.withMessageContaining("must be at least 1.41") .withMessageContaining("must be at least 1.41")
.withMessageContaining("current API version is 1.24"); .withMessageContaining("current API version is 1.24");
@ -446,30 +400,6 @@ class DockerApiTests {
URI imageUri = new URI(IMAGES_URL + "/docker.io/paketobuildpacks/builder:base/json"); URI imageUri = new URI(IMAGES_URL + "/docker.io/paketobuildpacks/builder:base/json");
given(http().get(imageUri)).willReturn(responseOf("type/image.json")); given(http().get(imageUri)).willReturn(responseOf("type/image.json"));
Image image = this.api.inspect(reference); Image image = this.api.inspect(reference);
assertThat(image.getArchitecture()).isEqualTo("amd64");
assertThat(image.getLayers()).hasSize(46);
}
@Test
void inspectWithPlatformWhenSupportedVersionInspectImage() throws Exception {
ImageReference reference = ImageReference.of("docker.io/paketobuildpacks/builder:base");
URI imageUri = new URI("/v1.49/images/docker.io/paketobuildpacks/builder:base/json?platform="
+ ENCODED_LINUX_ARM64_PLATFORM_JSON);
setVersion("1.49");
given(http().get(imageUri)).willReturn(responseOf("type/image-platform.json"));
Image image = this.api.inspect(reference, LINUX_ARM64_PLATFORM);
assertThat(image.getArchitecture()).isEqualTo("arm64");
assertThat(image.getLayers()).hasSize(2);
}
@Test
void inspectWithPlatformWhenOldVersionInspectImage() throws Exception {
ImageReference reference = ImageReference.of("docker.io/paketobuildpacks/builder:base");
URI imageUri = new URI("/v1.48/images/docker.io/paketobuildpacks/builder:base/json");
setVersion("1.48");
given(http().get(imageUri)).willReturn(responseOf("type/image.json"));
Image image = this.api.inspect(reference, LINUX_ARM64_PLATFORM);
assertThat(image.getArchitecture()).isEqualTo("amd64");
assertThat(image.getLayers()).hasSize(46); assertThat(image.getLayers()).hasSize(46);
} }
@ -501,65 +431,6 @@ class DockerApiTests {
.containsExactly("/cnb/stack.toml"); .containsExactly("/cnb/stack.toml");
} }
@Test
void exportLayersExportsLayerTarsWithPlatformWhenSupportedVersion() throws Exception {
setVersion("1.48");
ImageReference reference = ImageReference.of("docker.io/paketobuildpacks/builder:base");
URI exportUri = new URI("/v1.48/images/docker.io/paketobuildpacks/builder:base/get?platform="
+ ENCODED_LINUX_ARM64_PLATFORM_JSON);
given(DockerApiTests.this.http.get(exportUri)).willReturn(responseOf("export.tar"));
MultiValueMap<String, String> contents = new LinkedMultiValueMap<>();
this.api.exportLayers(reference, LINUX_ARM64_PLATFORM, (name, archive) -> {
ByteArrayOutputStream out = new ByteArrayOutputStream();
archive.writeTo(out);
try (TarArchiveInputStream in = new TarArchiveInputStream(
new ByteArrayInputStream(out.toByteArray()))) {
TarArchiveEntry entry = in.getNextEntry();
while (entry != null) {
contents.add(name, entry.getName());
entry = in.getNextEntry();
}
}
});
assertThat(contents).hasSize(3)
.containsKeys("70bb7a3115f3d5c01099852112c7e05bf593789e510468edb06b6a9a11fa3b73/layer.tar",
"74a9a50ece13c025cf10e9110d9ddc86c995079c34e2a22a28d1a3d523222c6e/layer.tar",
"a69532b5b92bb891fbd9fa1a6b3af9087ea7050255f59ba61a796f8555ecd783/layer.tar");
assertThat(contents.get("70bb7a3115f3d5c01099852112c7e05bf593789e510468edb06b6a9a11fa3b73/layer.tar"))
.containsExactly("/cnb/order.toml");
assertThat(contents.get("74a9a50ece13c025cf10e9110d9ddc86c995079c34e2a22a28d1a3d523222c6e/layer.tar"))
.containsExactly("/cnb/stack.toml");
}
@Test
void exportLayersExportsLayerTarsWithPlatformWhenOldVersionInspectImage() throws Exception {
setVersion("1.47");
ImageReference reference = ImageReference.of("docker.io/paketobuildpacks/builder:base");
URI exportUri = new URI("/v1.47/images/docker.io/paketobuildpacks/builder:base/get");
given(DockerApiTests.this.http.get(exportUri)).willReturn(responseOf("export.tar"));
MultiValueMap<String, String> contents = new LinkedMultiValueMap<>();
this.api.exportLayers(reference, LINUX_ARM64_PLATFORM, (name, archive) -> {
ByteArrayOutputStream out = new ByteArrayOutputStream();
archive.writeTo(out);
try (TarArchiveInputStream in = new TarArchiveInputStream(
new ByteArrayInputStream(out.toByteArray()))) {
TarArchiveEntry entry = in.getNextEntry();
while (entry != null) {
contents.add(name, entry.getName());
entry = in.getNextEntry();
}
}
});
assertThat(contents).hasSize(3)
.containsKeys("70bb7a3115f3d5c01099852112c7e05bf593789e510468edb06b6a9a11fa3b73/layer.tar",
"74a9a50ece13c025cf10e9110d9ddc86c995079c34e2a22a28d1a3d523222c6e/layer.tar",
"a69532b5b92bb891fbd9fa1a6b3af9087ea7050255f59ba61a796f8555ecd783/layer.tar");
assertThat(contents.get("70bb7a3115f3d5c01099852112c7e05bf593789e510468edb06b6a9a11fa3b73/layer.tar"))
.containsExactly("/cnb/order.toml");
assertThat(contents.get("74a9a50ece13c025cf10e9110d9ddc86c995079c34e2a22a28d1a3d523222c6e/layer.tar"))
.containsExactly("/cnb/stack.toml");
}
@Test @Test
void exportLayersWithSymlinksExportsLayerTars() throws Exception { void exportLayersWithSymlinksExportsLayerTars() throws Exception {
ImageReference reference = ImageReference.of("docker.io/paketobuildpacks/builder:base"); ImageReference reference = ImageReference.of("docker.io/paketobuildpacks/builder:base");
@ -693,19 +564,7 @@ class DockerApiTests {
@Test @Test
void createWithPlatformCreatesContainer() throws Exception { void createWithPlatformCreatesContainer() throws Exception {
ImageReference imageReference = ImageReference.of("ubuntu:bionic"); createWithPlatform("1.41");
ContainerConfig config = ContainerConfig.of(imageReference, (update) -> update.withCommand("/bin/bash"));
ImagePlatform platform = ImagePlatform.of("linux/arm64/v1");
setVersion("1.41");
URI createUri = new URI("/v1.41/containers/create?platform=linux%2Farm64%2Fv1");
given(http().post(eq(createUri), eq("application/json"), any()))
.willReturn(responseOf("create-container-response.json"));
ContainerReference containerReference = this.api.create(config, platform);
assertThat(containerReference).hasToString("e90e34656806");
then(http()).should().post(any(), any(), this.writer.capture());
ByteArrayOutputStream out = new ByteArrayOutputStream();
this.writer.getValue().accept(out);
assertThat(out.toByteArray()).hasSize(config.toString().length());
} }
@Test @Test
@ -717,7 +576,11 @@ class DockerApiTests {
ImageReference imageReference = ImageReference.of("ubuntu:bionic"); ImageReference imageReference = ImageReference.of("ubuntu:bionic");
ContainerConfig config = ContainerConfig.of(imageReference, (update) -> update.withCommand("/bin/bash")); ContainerConfig config = ContainerConfig.of(imageReference, (update) -> update.withCommand("/bin/bash"));
ImagePlatform platform = ImagePlatform.of("linux/arm64/v1"); ImagePlatform platform = ImagePlatform.of("linux/arm64/v1");
URI createUri = new URI(CONTAINERS_URL + "/create?platform=linux%2Farm64%2Fv1"); if (apiVersion != null) {
given(http().head(eq(new URI(PING_URL))))
.willReturn(responseWithHeaders(new BasicHeader(DockerApi.API_VERSION_HEADER_NAME, apiVersion)));
}
URI createUri = new URI(PLATFORM_CONTAINERS_URL + "/create?platform=linux%2Farm64%2Fv1");
given(http().post(eq(createUri), eq("application/json"), any())) given(http().post(eq(createUri), eq("application/json"), any()))
.willReturn(responseOf("create-container-response.json")); .willReturn(responseOf("create-container-response.json"));
ContainerReference containerReference = this.api.create(config, platform); ContainerReference containerReference = this.api.create(config, platform);
@ -733,7 +596,8 @@ class DockerApiTests {
ImageReference imageReference = ImageReference.of("ubuntu:bionic"); ImageReference imageReference = ImageReference.of("ubuntu:bionic");
ContainerConfig config = ContainerConfig.of(imageReference, (update) -> update.withCommand("/bin/bash")); ContainerConfig config = ContainerConfig.of(imageReference, (update) -> update.withCommand("/bin/bash"));
ImagePlatform platform = ImagePlatform.of("linux/arm64/v1"); ImagePlatform platform = ImagePlatform.of("linux/arm64/v1");
setVersion("1.24"); given(http().head(eq(new URI(PING_URL))))
.willReturn(responseWithHeaders(new BasicHeader(DockerApi.API_VERSION_HEADER_NAME, "1.24")));
assertThatIllegalStateException().isThrownBy(() -> this.api.create(config, platform)) assertThatIllegalStateException().isThrownBy(() -> this.api.create(config, platform))
.withMessageContaining("must be at least 1.41") .withMessageContaining("must be at least 1.41")
.withMessageContaining("current API version is 1.24"); .withMessageContaining("current API version is 1.24");

View File

@ -64,20 +64,6 @@ class ImagePlatformTests extends AbstractJsonTests {
assertThat(platform.toString()).isEqualTo("linux/amd64/v1"); assertThat(platform.toString()).isEqualTo("linux/amd64/v1");
} }
@Test
void toJsonString() {
ImagePlatform platform = ImagePlatform.of("linux/amd64/v1");
assertThat(platform.toJson()).isEqualTo("""
{"os":"linux","architecture":"amd64","variant":"v1"}""");
}
@Test
void toJsonStringWhenOnlyOs() {
ImagePlatform platform = ImagePlatform.of("linux");
assertThat(platform.toJson()).isEqualTo("""
{"os":"linux"}""");
}
private Image getImage() throws IOException { private Image getImage() throws IOException {
return Image.of(getContent("type/image.json")); return Image.of(getContent("type/image.json"));
} }

View File

@ -76,7 +76,7 @@ class DockerRegistryConfigAuthenticationTests {
} }
""") """)
@Test @Test
void getAuthHeaderWhenAuthForDockerDomain(@ResourcesRoot Path directory) { void getAuthHeaderWhenAuthForDockerDomain(@ResourcesRoot Path directory) throws Exception {
this.environment.put("DOCKER_CONFIG", directory.toString()); this.environment.put("DOCKER_CONFIG", directory.toString());
ImageReference imageReference = ImageReference.of("docker.io/ubuntu:latest"); ImageReference imageReference = ImageReference.of("docker.io/ubuntu:latest");
String authHeader = getAuthHeader(imageReference); String authHeader = getAuthHeader(imageReference);
@ -99,7 +99,7 @@ class DockerRegistryConfigAuthenticationTests {
} }
""") """)
@Test @Test
void getAuthHeaderWhenAuthForLegacyDockerDomain(@ResourcesRoot Path directory) { void getAuthHeaderWhenAuthForLegacyDockerDomain(@ResourcesRoot Path directory) throws Exception {
this.environment.put("DOCKER_CONFIG", directory.toString()); this.environment.put("DOCKER_CONFIG", directory.toString());
ImageReference imageReference = ImageReference.of("index.docker.io/ubuntu:latest"); ImageReference imageReference = ImageReference.of("index.docker.io/ubuntu:latest");
String authHeader = getAuthHeader(imageReference); String authHeader = getAuthHeader(imageReference);
@ -121,7 +121,7 @@ class DockerRegistryConfigAuthenticationTests {
} }
""") """)
@Test @Test
void getAuthHeaderWhenAuthForCustomDomain(@ResourcesRoot Path directory) { void getAuthHeaderWhenAuthForCustomDomain(@ResourcesRoot Path directory) throws Exception {
this.environment.put("DOCKER_CONFIG", directory.toString()); this.environment.put("DOCKER_CONFIG", directory.toString());
ImageReference imageReference = ImageReference.of("my-registry.example.com/ubuntu:latest"); ImageReference imageReference = ImageReference.of("my-registry.example.com/ubuntu:latest");
String authHeader = getAuthHeader(imageReference); String authHeader = getAuthHeader(imageReference);
@ -143,7 +143,7 @@ class DockerRegistryConfigAuthenticationTests {
} }
""") """)
@Test @Test
void getAuthHeaderWhenAuthForCustomDomainWithLegacyFormat(@ResourcesRoot Path directory) { void getAuthHeaderWhenAuthForCustomDomainWithLegacyFormat(@ResourcesRoot Path directory) throws Exception {
this.environment.put("DOCKER_CONFIG", directory.toString()); this.environment.put("DOCKER_CONFIG", directory.toString());
ImageReference imageReference = ImageReference.of("my-registry.example.com/ubuntu:latest"); ImageReference imageReference = ImageReference.of("my-registry.example.com/ubuntu:latest");
String authHeader = getAuthHeader(imageReference); String authHeader = getAuthHeader(imageReference);
@ -160,7 +160,7 @@ class DockerRegistryConfigAuthenticationTests {
} }
""") """)
@Test @Test
void getAuthHeaderWhenEmptyConfigDirectoryReturnsFallback(@ResourcesRoot Path directory) { void getAuthHeaderWhenEmptyConfigDirectoryReturnsFallback(@ResourcesRoot Path directory) throws Exception {
this.environment.put("DOCKER_CONFIG", directory.toString()); this.environment.put("DOCKER_CONFIG", directory.toString());
ImageReference imageReference = ImageReference.of("docker.io/ubuntu:latest"); ImageReference imageReference = ImageReference.of("docker.io/ubuntu:latest");
String authHeader = getAuthHeader(imageReference, DockerRegistryAuthentication.EMPTY_USER); String authHeader = getAuthHeader(imageReference, DockerRegistryAuthentication.EMPTY_USER);

View File

@ -23,7 +23,6 @@ import java.util.Map;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.buildpack.platform.docker.type.Image.Descriptor;
import org.springframework.boot.buildpack.platform.json.AbstractJsonTests; import org.springframework.boot.buildpack.platform.json.AbstractJsonTests;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@ -99,35 +98,6 @@ class ImageTests extends AbstractJsonTests {
assertThat(image.getCreated()).isEqualTo("2019-10-30T19:34:56.296666503Z"); assertThat(image.getCreated()).isEqualTo("2019-10-30T19:34:56.296666503Z");
} }
@Test
void getDescriptorReturnsDescriptor() throws Exception {
Image image = getImage();
Descriptor descriptor = image.getDescriptor();
assertThat(descriptor).isNotNull();
assertThat(descriptor.getDigest())
.isEqualTo("sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96");
}
@Test
void getPrimaryDigestWhenHasDescriptor() throws Exception {
Image image = getImage();
assertThat(image.getPrimaryDigest())
.isEqualTo("sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96");
}
@Test
void getPrimaryDigestWhenNoDescriptor() throws Exception {
Image image = Image.of(getContent("image-no-descriptor.json"));
assertThat(image.getPrimaryDigest())
.isEqualTo("sha256:21635a6b4880772f3fabbf8b660907fa38636558cf787cc26f1779fc4b4e2cba");
}
@Test
void getPrimaryDigestWhenNoDigest() throws Exception {
Image image = Image.of(getContent("image-no-digest.json"));
assertThat(image.getPrimaryDigest()).isNull();
}
private Image getImage() throws IOException { private Image getImage() throws IOException {
return Image.of(getContent("image.json")); return Image.of(getContent("image.json"));
} }

View File

@ -31,7 +31,7 @@ class FileDescriptorTests {
private final int sourceHandle = 123; private final int sourceHandle = 123;
private int closedHandle; private int closedHandle = 0;
@Test @Test
void acquireReturnsHandle() throws Exception { void acquireReturnsHandle() throws Exception {

View File

@ -1,25 +0,0 @@
{
"Id": "sha256:21635a6b4880772f3fabbf8b660907fa38636558cf787cc26f1779fc4b4e2cba",
"RepoTags": [
"ghcr.io/spring-io/spring-boot-cnb-test-builder:0.0.1"
],
"RepoDigests": [
"ghcr.io/spring-io/spring-boot-cnb-test-builder@sha256:21635a6b4880772f3fabbf8b660907fa38636558cf787cc26f1779fc4b4e2cba"
],
"Parent": "",
"Comment": "",
"DockerVersion": "",
"Author": "",
"Config": null,
"Architecture": "",
"Os": "",
"Size": 166797518,
"GraphDriver": {
"Data": null,
"Name": "overlayfs"
},
"RootFS": {},
"Metadata": {
"LastTagTime": "2025-04-10T22:41:27.520294922Z"
}
}

View File

@ -1,22 +0,0 @@
{
"Id": "sha256:21635a6b4880772f3fabbf8b660907fa38636558cf787cc26f1779fc4b4e2cba",
"RepoTags": [
"ghcr.io/spring-io/spring-boot-cnb-test-builder:0.0.1"
],
"Parent": "",
"Comment": "",
"DockerVersion": "",
"Author": "",
"Config": null,
"Architecture": "",
"Os": "",
"Size": 166797518,
"GraphDriver": {
"Data": null,
"Name": "overlayfs"
},
"RootFS": {},
"Metadata": {
"LastTagTime": "2025-04-10T22:41:27.520294922Z"
}
}

View File

@ -1,27 +1,5 @@
{ {
"Id": "sha256:9b450bffdb05bcf660d464d0bfdf344ee6ca38e9b8de4f408c8080b0c9319349", "Id": "sha256:9b450bffdb05bcf660d464d0bfdf344ee6ca38e9b8de4f408c8080b0c9319349",
"Descriptor": {
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96",
"size": 424,
"urls": [
"https://example.com"
],
"annotations": {
"com.docker.official-images.bashbrew.arch": "amd64",
"org.opencontainers.image.version": "24.04"
},
"data": null,
"platform": {
"architecture": "arm",
"os": "windows",
"os.version": "10.0.19041.1165",
"os.features": [
],
"variant": "v7"
},
"artifactType": null
},
"RepoTags": [ "RepoTags": [
"paketo-buildpacks/cnb:latest" "paketo-buildpacks/cnb:latest"
], ],

View File

@ -16,7 +16,6 @@
<module name="com.puppycrawl.tools.checkstyle.TreeWalker"> <module name="com.puppycrawl.tools.checkstyle.TreeWalker">
<module name="io.spring.javaformat.checkstyle.check.SpringDeprecatedCheck" /> <module name="io.spring.javaformat.checkstyle.check.SpringDeprecatedCheck" />
<module name="io.spring.javaformat.checkstyle.check.SpringJUnit5Check" /> <module name="io.spring.javaformat.checkstyle.check.SpringJUnit5Check" />
<module name="ExplicitInitialization" />
<module <module
name="com.puppycrawl.tools.checkstyle.checks.imports.IllegalImportCheck"> name="com.puppycrawl.tools.checkstyle.checks.imports.IllegalImportCheck">
<property name="regexp" value="true" /> <property name="regexp" value="true" />

View File

@ -46,7 +46,7 @@ architectureCheck {
def dependenciesOf(String version) { def dependenciesOf(String version) {
if (version.startsWith("4.0")) { if (version.startsWith("4.0")) {
return [ def modules = [
"spring-boot", "spring-boot",
"spring-boot-activemq", "spring-boot-activemq",
"spring-boot-actuator", "spring-boot-actuator",
@ -105,8 +105,6 @@ def dependenciesOf(String version) {
"spring-boot-micrometer-metrics-test", "spring-boot-micrometer-metrics-test",
"spring-boot-micrometer-observation", "spring-boot-micrometer-observation",
"spring-boot-micrometer-tracing", "spring-boot-micrometer-tracing",
"spring-boot-micrometer-tracing-brave",
"spring-boot-micrometer-tracing-opentelemetry",
"spring-boot-micrometer-tracing-test", "spring-boot-micrometer-tracing-test",
"spring-boot-mongodb", "spring-boot-mongodb",
"spring-boot-mustache", "spring-boot-mustache",
@ -150,6 +148,13 @@ def dependenciesOf(String version) {
"spring-boot-webservices-test", "spring-boot-webservices-test",
"spring-boot-zipkin" "spring-boot-zipkin"
] ]
if (version.equals("4.0.0-RC2")) {
modules += [
"spring-boot-micrometer-tracing-brave",
"spring-boot-micrometer-tracing-opentelemetry"
]
}
return modules
} }
return [ return [
"spring-boot", "spring-boot",

View File

@ -211,9 +211,8 @@ public class AutoConfigurationImportSelector implements DeferredImportSelector,
private void checkExcludedClasses(List<String> configurations, Set<String> exclusions) { private void checkExcludedClasses(List<String> configurations, Set<String> exclusions) {
List<String> invalidExcludes = new ArrayList<>(exclusions.size()); List<String> invalidExcludes = new ArrayList<>(exclusions.size());
ClassLoader classLoader = (this.beanClassLoader != null) ? this.beanClassLoader : getClass().getClassLoader();
for (String exclusion : exclusions) { for (String exclusion : exclusions) {
if (ClassUtils.isPresent(exclusion, classLoader) && !configurations.contains(exclusion)) { if (ClassUtils.isPresent(exclusion, getClass().getClassLoader()) && !configurations.contains(exclusion)) {
invalidExcludes.add(exclusion); invalidExcludes.add(exclusion);
} }
} }

View File

@ -472,19 +472,13 @@ class OnBeanCondition extends FilteringSpringBootCondition implements Configurat
} }
private List<String> getPrimaryBeans(Map<String, @Nullable BeanDefinition> beanDefinitions) { private List<String> getPrimaryBeans(Map<String, @Nullable BeanDefinition> beanDefinitions) {
return getMatchingBeans(beanDefinitions, this::isPrimary); return getMatchingBeans(beanDefinitions,
} (beanDefinition) -> beanDefinition != null && beanDefinition.isPrimary());
private boolean isPrimary(@Nullable BeanDefinition beanDefinition) {
return (beanDefinition != null) && beanDefinition.isPrimary();
} }
private List<String> getNonFallbackBeans(Map<String, @Nullable BeanDefinition> beanDefinitions) { private List<String> getNonFallbackBeans(Map<String, @Nullable BeanDefinition> beanDefinitions) {
return getMatchingBeans(beanDefinitions, this::isNotFallback); return getMatchingBeans(beanDefinitions,
} Predicate.not((beanDefinition) -> beanDefinition != null && beanDefinition.isFallback()));
private boolean isNotFallback(@Nullable BeanDefinition beanDefinition) {
return (beanDefinition == null) || !beanDefinition.isFallback();
} }
private List<String> getMatchingBeans(Map<String, @Nullable BeanDefinition> beanDefinitions, private List<String> getMatchingBeans(Map<String, @Nullable BeanDefinition> beanDefinitions,

View File

@ -76,13 +76,13 @@ public class MessageSourceProperties {
* Whether to always apply the MessageFormat rules, parsing even messages without * Whether to always apply the MessageFormat rules, parsing even messages without
* arguments. * arguments.
*/ */
private boolean alwaysUseMessageFormat; private boolean alwaysUseMessageFormat = false;
/** /**
* Whether to use the message code as the default message instead of throwing a * Whether to use the message code as the default message instead of throwing a
* "NoSuchMessageException". Recommended during development only. * "NoSuchMessageException". Recommended during development only.
*/ */
private boolean useCodeAsDefaultMessage; private boolean useCodeAsDefaultMessage = false;
public List<String> getBasename() { public List<String> getBasename() {
return this.basename; return this.basename;

View File

@ -33,12 +33,12 @@ public class JmxProperties {
/** /**
* Expose Spring's management beans to the JMX domain. * Expose Spring's management beans to the JMX domain.
*/ */
private boolean enabled; private boolean enabled = false;
/** /**
* Whether unique runtime object names should be ensured. * Whether unique runtime object names should be ensured.
*/ */
private boolean uniqueNames; private boolean uniqueNames = false;
/** /**
* MBeanServer bean name. * MBeanServer bean name.

View File

@ -24,7 +24,6 @@ import java.util.concurrent.TimeUnit;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;
import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.context.properties.PropertyMapper;
import org.springframework.boot.convert.DurationUnit; import org.springframework.boot.convert.DurationUnit;
import org.springframework.http.CacheControl; import org.springframework.http.CacheControl;
@ -51,9 +50,6 @@ public class WebProperties {
private final Resources resources = new Resources(); private final Resources resources = new Resources();
@NestedConfigurationProperty
private final ErrorProperties error = new ErrorProperties();
public @Nullable Locale getLocale() { public @Nullable Locale getLocale() {
return this.locale; return this.locale;
} }
@ -70,10 +66,6 @@ public class WebProperties {
this.localeResolver = localeResolver; this.localeResolver = localeResolver;
} }
public ErrorProperties getError() {
return this.error;
}
public Resources getResources() { public Resources getResources() {
return this.resources; return this.resources;
} }
@ -109,7 +101,7 @@ public class WebProperties {
*/ */
private boolean addMappings = true; private boolean addMappings = true;
private boolean customized; private boolean customized = false;
private final Chain chain = new Chain(); private final Chain chain = new Chain();
@ -159,7 +151,7 @@ public class WebProperties {
*/ */
public static class Chain { public static class Chain {
boolean customized; boolean customized = false;
/** /**
* Whether to enable the Spring Resource Handling chain. By default, disabled * Whether to enable the Spring Resource Handling chain. By default, disabled
@ -177,7 +169,7 @@ public class WebProperties {
* brotli). Checks for a resource name with the '.gz' or '.br' file * brotli). Checks for a resource name with the '.gz' or '.br' file
* extensions. * extensions.
*/ */
private boolean compressed; private boolean compressed = false;
private final Strategy strategy = new Strategy(); private final Strategy strategy = new Strategy();
@ -254,7 +246,7 @@ public class WebProperties {
*/ */
public static class Content { public static class Content {
private boolean customized; private boolean customized = false;
/** /**
* Whether to enable the content Version Strategy. * Whether to enable the content Version Strategy.
@ -295,7 +287,7 @@ public class WebProperties {
*/ */
public static class Fixed { public static class Fixed {
private boolean customized; private boolean customized = false;
/** /**
* Whether to enable the fixed Version Strategy. * Whether to enable the fixed Version Strategy.
@ -354,7 +346,7 @@ public class WebProperties {
*/ */
public static class Cache { public static class Cache {
private boolean customized; private boolean customized = false;
/** /**
* Cache period for the resources served by the resource handler. If a * Cache period for the resources served by the resource handler. If a
@ -406,7 +398,7 @@ public class WebProperties {
*/ */
public static class Cachecontrol { public static class Cachecontrol {
private boolean customized; private boolean customized = false;
/** /**
* Maximum time the response should be cached, in seconds if no duration * Maximum time the response should be cached, in seconds if no duration

View File

@ -36,8 +36,7 @@
"description": "Resource reference to a generated git info properties file.", "description": "Resource reference to a generated git info properties file.",
"deprecation": { "deprecation": {
"replacement": "spring.info.git.location", "replacement": "spring.info.git.location",
"level": "error", "level": "error"
"since": "1.4.0"
} }
}, },
{ {
@ -59,8 +58,7 @@
"type": "java.lang.Boolean", "type": "java.lang.Boolean",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.add-mappings", "replacement": "spring.web.resources.add-mappings",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -68,8 +66,7 @@
"type": "java.lang.Boolean", "type": "java.lang.Boolean",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.cache-private", "replacement": "spring.web.resources.cache.cachecontrol.cache-private",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -77,8 +74,7 @@
"type": "java.lang.Boolean", "type": "java.lang.Boolean",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.cache-public", "replacement": "spring.web.resources.cache.cachecontrol.cache-public",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -86,8 +82,7 @@
"type": "java.time.Duration", "type": "java.time.Duration",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.max-age", "replacement": "spring.web.resources.cache.cachecontrol.max-age",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -95,8 +90,7 @@
"type": "java.lang.Boolean", "type": "java.lang.Boolean",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.must-revalidate", "replacement": "spring.web.resources.cache.cachecontrol.must-revalidate",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -104,8 +98,7 @@
"type": "java.lang.Boolean", "type": "java.lang.Boolean",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.no-cache", "replacement": "spring.web.resources.cache.cachecontrol.no-cache",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -113,8 +106,7 @@
"type": "java.lang.Boolean", "type": "java.lang.Boolean",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.no-store", "replacement": "spring.web.resources.cache.cachecontrol.no-store",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -122,8 +114,7 @@
"type": "java.lang.Boolean", "type": "java.lang.Boolean",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.no-transform", "replacement": "spring.web.resources.cache.cachecontrol.no-transform",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -131,8 +122,7 @@
"type": "java.lang.Boolean", "type": "java.lang.Boolean",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.proxy-revalidate", "replacement": "spring.web.resources.cache.cachecontrol.proxy-revalidate",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -140,8 +130,7 @@
"type": "java.time.Duration", "type": "java.time.Duration",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.s-max-age", "replacement": "spring.web.resources.cache.cachecontrol.s-max-age",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -149,8 +138,7 @@
"type": "java.time.Duration", "type": "java.time.Duration",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.stale-if-error", "replacement": "spring.web.resources.cache.cachecontrol.stale-if-error",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -158,8 +146,7 @@
"type": "java.time.Duration", "type": "java.time.Duration",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.stale-while-revalidate", "replacement": "spring.web.resources.cache.cachecontrol.stale-while-revalidate",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -167,8 +154,7 @@
"type": "java.time.Duration", "type": "java.time.Duration",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.cache.period", "replacement": "spring.web.resources.cache.period",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -176,8 +162,7 @@
"type": "java.lang.Boolean", "type": "java.lang.Boolean",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.cache.use-last-modified", "replacement": "spring.web.resources.cache.use-last-modified",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -185,8 +170,7 @@
"type": "java.lang.Boolean", "type": "java.lang.Boolean",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.chain.cache", "replacement": "spring.web.resources.chain.cache",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -194,8 +178,7 @@
"type": "java.lang.Boolean", "type": "java.lang.Boolean",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.chain.compressed", "replacement": "spring.web.resources.chain.compressed",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -203,8 +186,7 @@
"type": "java.lang.Boolean", "type": "java.lang.Boolean",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.chain.enabled", "replacement": "spring.web.resources.chain.enabled",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -212,16 +194,14 @@
"type": "java.lang.Boolean", "type": "java.lang.Boolean",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.chain.compressed", "replacement": "spring.web.resources.chain.compressed",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
"name": "spring.resources.chain.html-application-cache", "name": "spring.resources.chain.html-application-cache",
"type": "java.lang.Boolean", "type": "java.lang.Boolean",
"deprecation": { "deprecation": {
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -229,8 +209,7 @@
"type": "java.lang.Boolean", "type": "java.lang.Boolean",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.chain.strategy.content.enabled", "replacement": "spring.web.resources.chain.strategy.content.enabled",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -238,8 +217,7 @@
"type": "java.lang.String[]", "type": "java.lang.String[]",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.chain.strategy.content.paths", "replacement": "spring.web.resources.chain.strategy.content.paths",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -247,8 +225,7 @@
"type": "java.lang.Boolean", "type": "java.lang.Boolean",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.chain.strategy.fixed.enabled", "replacement": "spring.web.resources.chain.strategy.fixed.enabled",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -256,8 +233,7 @@
"type": "java.lang.String[]", "type": "java.lang.String[]",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.chain.strategy.fixed.paths", "replacement": "spring.web.resources.chain.strategy.fixed.paths",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -265,8 +241,7 @@
"type": "java.lang.String", "type": "java.lang.String",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.chain.strategy.fixed.version", "replacement": "spring.web.resources.chain.strategy.fixed.version",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {
@ -274,8 +249,7 @@
"type": "java.lang.String[]", "type": "java.lang.String[]",
"deprecation": { "deprecation": {
"replacement": "spring.web.resources.static-locations", "replacement": "spring.web.resources.static-locations",
"level": "error", "level": "error"
"since": "2.4.0"
} }
}, },
{ {

View File

@ -1,6 +1,3 @@
defaults.spring.template.provider.cache=false defaults.spring.template.provider.cache=false
defaults.spring.web.error.include-binding-errors=always
defaults.spring.web.error.include-message=always
defaults.spring.web.error.include-stacktrace=always
defaults.spring.web.resources.cache.period=0 defaults.spring.web.resources.cache.period=0
defaults.spring.web.resources.chain.cache=false defaults.spring.web.resources.chain.cache=false

View File

@ -195,16 +195,6 @@ class ConditionalOnSingleCandidateTests {
}); });
} }
@Test
void singleCandidateDoesNotMatchWhenMultipleRegisteredAsSingletonCandidates() {
this.contextRunner.withInitializer((context) -> {
context.getBeanFactory().registerSingleton("alpha", "alpha");
context.getBeanFactory().registerSingleton("bravo", "bravo");
})
.withUserConfiguration(OnBeanSingleCandidateConfiguration.class)
.run((context) -> assertThat(context).doesNotHaveBean("consumer"));
}
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
@ConditionalOnSingleCandidate(String.class) @ConditionalOnSingleCandidate(String.class)
static class OnBeanSingleCandidateConfiguration { static class OnBeanSingleCandidateConfiguration {

View File

@ -130,7 +130,7 @@ class JmxAutoConfigurationTests {
@ManagedResource @ManagedResource
public static class Counter { public static class Counter {
private int counter; private int counter = 0;
@ManagedAttribute @ManagedAttribute
public int get() { public int get() {

View File

@ -336,7 +336,7 @@ class FileWatcherTests {
private CountDownLatch latch = new CountDownLatch(1); private CountDownLatch latch = new CountDownLatch(1);
volatile boolean changed; volatile boolean changed = false;
@Override @Override
public void run() { public void run() {

View File

@ -16,7 +16,6 @@
plugins { plugins {
id "java-library" id "java-library"
id "org.springframework.boot.configuration-metadata"
id "org.springframework.boot.deployed" id "org.springframework.boot.deployed"
id "org.springframework.boot.optional-dependencies" id "org.springframework.boot.optional-dependencies"
} }

View File

@ -9,9 +9,8 @@
{ {
"name": "spring.test.observability.auto-configure", "name": "spring.test.observability.auto-configure",
"deprecation": { "deprecation": {
"level": "error",
"reason": "Superseded by 'spring.test.metrics.export' and 'spring.test.tracing.export'.", "reason": "Superseded by 'spring.test.metrics.export' and 'spring.test.tracing.export'.",
"since": "4.0.0" "level": "error"
} }
}, },
{ {

View File

@ -46,7 +46,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class SpringBootTestWebEnvironmentMockTests { class SpringBootTestWebEnvironmentMockTests {
@Value("${value}") @Value("${value}")
private int value; private int value = 0;
@Autowired @Autowired
private WebApplicationContext context; private WebApplicationContext context;

View File

@ -144,7 +144,7 @@ class ImportTestcontainersTests {
@ImportTestcontainers @ImportTestcontainers
static class NullContainer { static class NullContainer {
static @Nullable PostgreSQLContainer container; static @Nullable PostgreSQLContainer container = null;
} }

View File

@ -63,7 +63,7 @@ class SpringApplicationShutdownHook implements Runnable {
private final AtomicBoolean shutdownHookAdded = new AtomicBoolean(); private final AtomicBoolean shutdownHookAdded = new AtomicBoolean();
private volatile boolean shutdownHookAdditionEnabled; private volatile boolean shutdownHookAdditionEnabled = false;
private boolean inProgress; private boolean inProgress;

View File

@ -48,7 +48,7 @@ class SpringBootExceptionHandler implements UncaughtExceptionHandler {
private final List<Throwable> loggedExceptions = new ArrayList<>(); private final List<Throwable> loggedExceptions = new ArrayList<>();
private int exitCode; private int exitCode = 0;
SpringBootExceptionHandler(@Nullable UncaughtExceptionHandler parent) { SpringBootExceptionHandler(@Nullable UncaughtExceptionHandler parent) {
this.parent = parent; this.parent = parent;

View File

@ -193,7 +193,7 @@ public class LoggingApplicationListener implements GenericApplicationListener {
private boolean parseArgs = true; private boolean parseArgs = true;
private @Nullable LogLevel springBootLogging; private @Nullable LogLevel springBootLogging = null;
@Override @Override
public boolean supportsEventType(ResolvableType resolvableType) { public boolean supportsEventType(ResolvableType resolvableType) {

View File

@ -373,7 +373,7 @@ class SpringIterableConfigurationPropertySource extends SpringConfigurationPrope
private final @Nullable ConfigurationPropertyName[] names; private final @Nullable ConfigurationPropertyName[] names;
private int index; private int index = 0;
ConfigurationPropertyNamesIterator(@Nullable ConfigurationPropertyName[] names) { ConfigurationPropertyNamesIterator(@Nullable ConfigurationPropertyName[] names) {
this.names = names; this.names = names;

View File

@ -87,7 +87,7 @@ public final class CorrelationIdFormatter {
* @return a formatted correlation id * @return a formatted correlation id
*/ */
public String format(UnaryOperator<@Nullable String> resolver) { public String format(UnaryOperator<@Nullable String> resolver) {
StringBuilder result = new StringBuilder(this.blank.length()); StringBuilder result = new StringBuilder();
formatTo(resolver, result); formatTo(resolver, result);
return result.toString(); return result.toString();
} }

View File

@ -39,7 +39,7 @@ public final class RetryPolicySettings {
/** /**
* Default number of retry attempts. * Default number of retry attempts.
*/ */
public static final long DEFAULT_MAX_RETRIES = RetryPolicy.Builder.DEFAULT_MAX_RETRIES; public static final long DEFAULT_MAX_ATTEMPTS = RetryPolicy.Builder.DEFAULT_MAX_ATTEMPTS;
/** /**
* Default initial delay. * Default initial delay.
@ -62,7 +62,7 @@ public final class RetryPolicySettings {
private @Nullable Predicate<Throwable> exceptionPredicate; private @Nullable Predicate<Throwable> exceptionPredicate;
private Long maxRetries = DEFAULT_MAX_RETRIES; private Long maxAttempts = DEFAULT_MAX_ATTEMPTS;
private Duration delay = DEFAULT_DELAY; private Duration delay = DEFAULT_DELAY;
@ -84,7 +84,7 @@ public final class RetryPolicySettings {
map.from(this::getExceptionIncludes).to(builder::includes); map.from(this::getExceptionIncludes).to(builder::includes);
map.from(this::getExceptionExcludes).to(builder::excludes); map.from(this::getExceptionExcludes).to(builder::excludes);
map.from(this::getExceptionPredicate).to(builder::predicate); map.from(this::getExceptionPredicate).to(builder::predicate);
map.from(this::getMaxRetries).to(builder::maxRetries); map.from(this::getMaxAttempts).to(builder::maxAttempts);
map.from(this::getDelay).to(builder::delay); map.from(this::getDelay).to(builder::delay);
map.from(this::getJitter).to(builder::jitter); map.from(this::getJitter).to(builder::jitter);
map.from(this::getMultiplier).to(builder::multiplier); map.from(this::getMultiplier).to(builder::multiplier);
@ -153,19 +153,18 @@ public final class RetryPolicySettings {
/** /**
* Return the maximum number of retry attempts. * Return the maximum number of retry attempts.
* @return the maximum number of retry attempts * @return the maximum number of retry attempts
* @see #DEFAULT_MAX_RETRIES * @see #DEFAULT_MAX_ATTEMPTS
*/ */
public Long getMaxRetries() { public Long getMaxAttempts() {
return this.maxRetries; return this.maxAttempts;
} }
/** /**
* Specify the maximum number of retry attempts. * Specify the maximum number of retry attempts.
* @param maxRetries the maximum number of retry attempts (must be equal or greater * @param maxAttempts the max attempts (must be equal or greater than zero)
* than zero)
*/ */
public void setMaxRetries(Long maxRetries) { public void setMaxAttempts(Long maxAttempts) {
this.maxRetries = maxRetries; this.maxAttempts = maxAttempts;
} }
/** /**

View File

@ -53,15 +53,14 @@ class SpringFactoriesEnvironmentPostProcessorsFactory implements EnvironmentPost
argumentResolver = argumentResolver.and(BootstrapRegistry.class, bootstrapContext); argumentResolver = argumentResolver.and(BootstrapRegistry.class, bootstrapContext);
List<Object> postProcessors = new ArrayList<>(); List<Object> postProcessors = new ArrayList<>();
postProcessors.addAll(this.loader.load(EnvironmentPostProcessor.class, argumentResolver)); postProcessors.addAll(this.loader.load(EnvironmentPostProcessor.class, argumentResolver));
postProcessors.addAll(loadDeprecatedPostProcessors(argumentResolver)); postProcessors.addAll(loadDeprecatedPostProcessors());
AnnotationAwareOrderComparator.sort(postProcessors); AnnotationAwareOrderComparator.sort(postProcessors);
return postProcessors.stream().map(Adapter::apply).collect(Collectors.toCollection(ArrayList::new)); return postProcessors.stream().map(Adapter::apply).collect(Collectors.toCollection(ArrayList::new));
} }
@SuppressWarnings("removal") @SuppressWarnings("removal")
private List<org.springframework.boot.env.EnvironmentPostProcessor> loadDeprecatedPostProcessors( private List<org.springframework.boot.env.EnvironmentPostProcessor> loadDeprecatedPostProcessors() {
ArgumentResolver argumentResolver) { return this.loader.load(org.springframework.boot.env.EnvironmentPostProcessor.class);
return this.loader.load(org.springframework.boot.env.EnvironmentPostProcessor.class, argumentResolver);
} }
@SuppressWarnings("removal") @SuppressWarnings("removal")

View File

@ -40,7 +40,6 @@ import org.springframework.util.StringUtils;
* {@link SystemEnvironmentOrigin} for every system environment property. * {@link SystemEnvironmentOrigin} for every system environment property.
* *
* @author Madhura Bhave * @author Madhura Bhave
* @author Phillip Webb
* @since 4.0.0 * @since 4.0.0
*/ */
public class SystemEnvironmentPropertySourceEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered { public class SystemEnvironmentPropertySourceEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {
@ -54,14 +53,10 @@ public class SystemEnvironmentPropertySourceEnvironmentPostProcessor implements
@Override @Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
postProcessEnvironment(environment, application.getEnvironmentPrefix());
}
private void postProcessEnvironment(ConfigurableEnvironment environment, @Nullable String environmentPrefix) {
String sourceName = StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME; String sourceName = StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME;
PropertySource<?> propertySource = environment.getPropertySources().get(sourceName); PropertySource<?> propertySource = environment.getPropertySources().get(sourceName);
if (propertySource != null) { if (propertySource != null) {
replacePropertySource(environment, sourceName, propertySource, environmentPrefix); replacePropertySource(environment, sourceName, propertySource, application.getEnvironmentPrefix());
} }
} }
@ -83,23 +78,6 @@ public class SystemEnvironmentPropertySourceEnvironmentPostProcessor implements
this.order = order; this.order = order;
} }
/**
* Post-process the given {@link ConfigurableEnvironment} by copying appropriate
* settings from a parent {@link ConfigurableEnvironment}.
* @param environment the environment to post-process
* @param parentEnvironment the parent environment
* @since 3.4.12
*/
public static void postProcessEnvironment(ConfigurableEnvironment environment,
ConfigurableEnvironment parentEnvironment) {
PropertySource<?> parentSystemEnvironmentPropertySource = parentEnvironment.getPropertySources()
.get(StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME);
if (parentSystemEnvironmentPropertySource instanceof OriginAwareSystemEnvironmentPropertySource parentOriginAwareSystemEnvironmentPropertySource) {
new SystemEnvironmentPropertySourceEnvironmentPostProcessor().postProcessEnvironment(environment,
parentOriginAwareSystemEnvironmentPropertySource.getPrefix());
}
}
/** /**
* {@link SystemEnvironmentPropertySource} that also tracks {@link Origin}. * {@link SystemEnvironmentPropertySource} that also tracks {@link Origin}.
*/ */

View File

@ -48,8 +48,7 @@
"description": "Log file name (for instance, `myapp.log`). Names can be an exact location or relative to the current directory.", "description": "Log file name (for instance, `myapp.log`). Names can be an exact location or relative to the current directory.",
"deprecation": { "deprecation": {
"replacement": "logging.file.name", "replacement": "logging.file.name",
"level": "error", "level": "error"
"since": "2.2.0"
} }
}, },
{ {
@ -59,8 +58,7 @@
"sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener", "sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener",
"defaultValue": false, "defaultValue": false,
"deprecation": { "deprecation": {
"replacement": "logging.logback.rollingpolicy.clean-history-on-start", "replacement": "logging.logback.rollingpolicy.clean-history-on-start"
"since": "2.4.0"
} }
}, },
{ {
@ -70,8 +68,7 @@
"sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener", "sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener",
"defaultValue": 7, "defaultValue": 7,
"deprecation": { "deprecation": {
"replacement": "logging.logback.rollingpolicy.max-history", "replacement": "logging.logback.rollingpolicy.max-history"
"since": "2.4.0"
} }
}, },
{ {
@ -81,8 +78,7 @@
"sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener", "sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener",
"defaultValue": "10MB", "defaultValue": "10MB",
"deprecation": { "deprecation": {
"replacement": "logging.logback.rollingpolicy.max-file-size", "replacement": "logging.logback.rollingpolicy.max-file-size"
"since": "2.4.0"
} }
}, },
{ {
@ -104,8 +100,7 @@
"sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener", "sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener",
"defaultValue": "0B", "defaultValue": "0B",
"deprecation": { "deprecation": {
"replacement": "logging.logback.rollingpolicy.total-size-cap", "replacement": "logging.logback.rollingpolicy.total-size-cap"
"since": "2.4.0"
} }
}, },
{ {
@ -180,8 +175,7 @@
"description": "Location of the log file. For instance, `/var/log`.", "description": "Location of the log file. For instance, `/var/log`.",
"deprecation": { "deprecation": {
"replacement": "logging.file.path", "replacement": "logging.file.path",
"level": "error", "level": "error"
"since": "2.2.0"
} }
}, },
{ {
@ -223,8 +217,7 @@
"sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener", "sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener",
"defaultValue": "${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz", "defaultValue": "${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz",
"deprecation": { "deprecation": {
"replacement": "logging.logback.rollingpolicy.file-name-pattern", "replacement": "logging.logback.rollingpolicy.file-name-pattern"
"since": "2.4.0"
} }
}, },
{ {
@ -362,8 +355,7 @@
"description": "Application index.", "description": "Application index.",
"deprecation": { "deprecation": {
"level": "error", "level": "error",
"reason": "Application context ids are now unique by default.", "reason": "Application context ids are now unique by default."
"since": "2.0.0"
} }
}, },
{ {
@ -388,8 +380,7 @@
"type": "java.lang.Integer", "type": "java.lang.Integer",
"deprecation": { "deprecation": {
"level": "error", "level": "error",
"reason": "Support for image banners has been removed.", "reason": "Support for image banners has been removed."
"since": "3.0.0"
} }
}, },
{ {
@ -397,8 +388,7 @@
"type": "java.lang.Integer", "type": "java.lang.Integer",
"deprecation": { "deprecation": {
"level": "error", "level": "error",
"reason": "Support for image banners has been removed.", "reason": "Support for image banners has been removed."
"since": "3.0.0"
} }
}, },
{ {
@ -406,8 +396,7 @@
"type": "java.lang.Boolean", "type": "java.lang.Boolean",
"deprecation": { "deprecation": {
"level": "error", "level": "error",
"reason": "Support for image banners has been removed.", "reason": "Support for image banners has been removed."
"since": "3.0.0"
} }
}, },
{ {
@ -415,8 +404,7 @@
"type": "org.springframework.core.io.Resource", "type": "org.springframework.core.io.Resource",
"deprecation": { "deprecation": {
"level": "error", "level": "error",
"reason": "Support for image banners has been removed.", "reason": "Support for image banners has been removed."
"since": "3.0.0"
} }
}, },
{ {
@ -424,8 +412,7 @@
"type": "java.lang.Integer", "type": "java.lang.Integer",
"deprecation": { "deprecation": {
"level": "error", "level": "error",
"reason": "Support for image banners has been removed.", "reason": "Support for image banners has been removed."
"since": "3.0.0"
} }
}, },
{ {
@ -433,8 +420,7 @@
"type": "org.springframework.boot.ImageBanner$PixelMode", "type": "org.springframework.boot.ImageBanner$PixelMode",
"deprecation": { "deprecation": {
"level": "error", "level": "error",
"reason": "Support for image banners has been removed.", "reason": "Support for image banners has been removed."
"since": "3.0.0"
} }
}, },
{ {
@ -442,8 +428,7 @@
"type": "java.lang.Integer", "type": "java.lang.Integer",
"deprecation": { "deprecation": {
"level": "error", "level": "error",
"reason": "Support for image banners has been removed.", "reason": "Support for image banners has been removed."
"since": "3.0.0"
} }
}, },
{ {
@ -562,8 +547,7 @@
"description": "Display the banner when the application runs.", "description": "Display the banner when the application runs.",
"defaultValue": true, "defaultValue": true,
"deprecation": { "deprecation": {
"replacement": "spring.main.banner-mode", "replacement": "spring.main.banner-mode"
"since": "1.3.0"
} }
}, },
{ {
@ -584,8 +568,7 @@
"sourceType": "org.springframework.boot.SpringApplication", "sourceType": "org.springframework.boot.SpringApplication",
"description": "Run the application in a web environment (auto-detected by default).", "description": "Run the application in a web environment (auto-detected by default).",
"deprecation": { "deprecation": {
"replacement": "spring.main.web-application-type", "replacement": "spring.main.web-application-type"
"since": "2.0.0"
} }
}, },
{ {

View File

@ -22,7 +22,7 @@ import org.jspecify.annotations.Nullable;
class TestLog4J2LoggingSystem extends Log4J2LoggingSystem { class TestLog4J2LoggingSystem extends Log4J2LoggingSystem {
private boolean disableSelfInitialization; private boolean disableSelfInitialization = false;
TestLog4J2LoggingSystem(String contextName) { TestLog4J2LoggingSystem(String contextName) {
// Tests add resources to the thread context classloader // Tests add resources to the thread context classloader

View File

@ -128,7 +128,7 @@ class RetryPolicySettingsTests {
@Test @Test
void createRetryPolicyWithCustomAttributes() { void createRetryPolicyWithCustomAttributes() {
RetryPolicySettings settings = new RetryPolicySettings(); RetryPolicySettings settings = new RetryPolicySettings();
settings.setMaxRetries(10L); settings.setMaxAttempts(10L);
settings.setDelay(Duration.ofSeconds(2)); settings.setDelay(Duration.ofSeconds(2));
settings.setJitter(Duration.ofMillis(500)); settings.setJitter(Duration.ofMillis(500));
settings.setMultiplier(2.0); settings.setMultiplier(2.0);

View File

@ -111,19 +111,4 @@ class SystemEnvironmentPropertySourceEnvironmentPostProcessorTests {
assertThat(replaced.getPrefix()).isEqualTo("my"); assertThat(replaced.getPrefix()).isEqualTo("my");
} }
@Test
void postProcessWithParentEnvironmentShouldApplyPrefix() {
SpringApplication application = new SpringApplication();
application.setEnvironmentPrefix("my");
new SystemEnvironmentPropertySourceEnvironmentPostProcessor().postProcessEnvironment(this.environment,
application);
StandardEnvironment child = new StandardEnvironment();
SystemEnvironmentPropertySourceEnvironmentPostProcessor.postProcessEnvironment(child, this.environment);
OriginAwareSystemEnvironmentPropertySource replaced = (OriginAwareSystemEnvironmentPropertySource) child
.getPropertySources()
.get("systemEnvironment");
assertThat(replaced).isNotNull();
assertThat(replaced.getPrefix()).isEqualTo("my");
}
} }

View File

@ -150,9 +150,6 @@ dependencies {
implementation("io.micrometer:micrometer-tracing") implementation("io.micrometer:micrometer-tracing")
implementation("io.micrometer:micrometer-registry-graphite") implementation("io.micrometer:micrometer-registry-graphite")
implementation("io.micrometer:micrometer-registry-jmx") implementation("io.micrometer:micrometer-registry-jmx")
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
implementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure")
implementation("io.opentelemetry:opentelemetry-sdk-metrics")
implementation("io.opentelemetry.instrumentation:opentelemetry-logback-appender-1.0") implementation("io.opentelemetry.instrumentation:opentelemetry-logback-appender-1.0")
implementation("io.projectreactor.netty:reactor-netty-http") implementation("io.projectreactor.netty:reactor-netty-http")
implementation("jakarta.annotation:jakarta.annotation-api") implementation("jakarta.annotation:jakarta.annotation-api")
@ -370,12 +367,6 @@ def configurationPropertiesMetadataAggregate = aggregates.create("configurationP
usage = "configuration-properties-metadata" usage = "configuration-properties-metadata"
} }
def checkAggregatedSpringConfigurationMetadata = tasks.register("checkAggregatedSpringConfigurationMetadata", org.springframework.boot.build.context.properties.CheckAggregatedSpringConfigurationMetadata) {
configurationPropertyMetadata = configurationPropertiesMetadataAggregate.files
reportLocation = layout.buildDirectory.file("checkAggregatedSpringConfigurationMetadata/report.txt")
}
tasks.named("check") { dependsOn checkAggregatedSpringConfigurationMetadata }
tasks.register("documentConfigurationProperties", org.springframework.boot.build.context.properties.DocumentConfigurationProperties) { tasks.register("documentConfigurationProperties", org.springframework.boot.build.context.properties.DocumentConfigurationProperties) {
configurationPropertyMetadata = configurationPropertiesMetadataAggregate.files configurationPropertyMetadata = configurationPropertiesMetadataAggregate.files
deprecated = false deprecated = false

View File

@ -479,6 +479,8 @@
* xref:how-to:deployment/cloud.adoc#howto.deployment.cloud.aws.beanstalk.tomcat-platform[#deployment.cloud.aws.beanstalk.tomcat-platform] * xref:how-to:deployment/cloud.adoc#howto.deployment.cloud.aws.beanstalk.tomcat-platform[#deployment.cloud.aws.beanstalk.tomcat-platform]
* xref:how-to:deployment/cloud.adoc#howto.deployment.cloud.aws.beanstalk[#cloud-deployment-aws-beanstalk] * xref:how-to:deployment/cloud.adoc#howto.deployment.cloud.aws.beanstalk[#cloud-deployment-aws-beanstalk]
* xref:how-to:deployment/cloud.adoc#howto.deployment.cloud.aws.beanstalk[#deployment.cloud.aws.beanstalk] * xref:how-to:deployment/cloud.adoc#howto.deployment.cloud.aws.beanstalk[#deployment.cloud.aws.beanstalk]
* xref:how-to:deployment/cloud.adoc#howto.deployment.cloud.aws.summary[#cloud-deployment-aws-summary]
* xref:how-to:deployment/cloud.adoc#howto.deployment.cloud.aws.summary[#deployment.cloud.aws.summary]
* xref:how-to:deployment/cloud.adoc#howto.deployment.cloud.aws[#cloud-deployment-aws] * xref:how-to:deployment/cloud.adoc#howto.deployment.cloud.aws[#cloud-deployment-aws]
* xref:how-to:deployment/cloud.adoc#howto.deployment.cloud.aws[#deployment.cloud.aws] * xref:how-to:deployment/cloud.adoc#howto.deployment.cloud.aws[#deployment.cloud.aws]
* xref:how-to:deployment/cloud.adoc#howto.deployment.cloud.azure[#deployment.cloud.azure] * xref:how-to:deployment/cloud.adoc#howto.deployment.cloud.azure[#deployment.cloud.azure]

View File

@ -207,20 +207,41 @@ This file will not be packaged in your uber jar or your container.
[[howto.data-initialization.migration-tool.liquibase-tests]] [[howto.data-initialization.migration-tool.liquibase-tests]]
=== Use Liquibase for Test-only Migrations === Use Liquibase for Test-only Migrations
If you want to create Liquibase migrations which populate your test database, you can leverage https://docs.liquibase.com/reference-guide/changelog-attributes/what-are-contexts[Liquibase contexts]. If you want to create Liquibase migrations which populate your test database, you have to create a test changelog which also includes the production changelog.
See also the related https://www.liquibase.com/blog/contexts-vs-labels[blog post].
In practical terms, this translates into adding a `context:@test` attribute to changesets containing test data, for example: First, you need to configure Liquibase to use a different changelog when running the tests.
One way to do this is to create a Spring Boot `test` profile and put the Liquibase properties in there.
For that, create a file named `src/test/resources/application-test.properties` and put the following property in there:
[source,sql] [configprops,yaml]
---- ----
--liquibase formatted sql spring:
liquibase:
--changeset alice:1 context:@test change-log: "classpath:/db/changelog/db.changelog-test.yaml"
insert into project (id, name) values (1, 'Spring Boot');
---- ----
And using `spring.liquibase.contexts=test` in environments where you would like changesets containing test data to be applied. This configures Liquibase to use a different changelog when running in the `test` profile.
Now create the changelog file at `src/test/resources/db/changelog/db.changelog-test.yaml`:
[source,yaml]
----
databaseChangeLog:
- include:
file: classpath:/db/changelog/db.changelog-master.yaml
- changeSet:
runOrder: "last"
id: "test"
changes:
# Insert your changes here
----
This changelog will be used when the tests are run and it will not be packaged in your uber jar or your container.
It includes the production changelog and then declares a new changeset, whose `runOrder: last` setting specifies that it runs after all the production changesets have been run.
You can now use for example the https://docs.liquibase.com/change-types/insert.html[insert changeset] to insert data or the https://docs.liquibase.com/change-types/sql.html[sql changeset] to execute SQL directly.
The last thing to do is to configure Spring Boot to activate the `test` profile when running tests.
To do this, you can add the `@ActiveProfiles("test")` annotation to your javadoc:org.springframework.boot.test.context.SpringBootTest[format=annotation] annotated test classes.

View File

@ -252,25 +252,17 @@ https://www.openshift.com/[OpenShift] has many resources describing how to deplo
[[howto.deployment.cloud.aws]] [[howto.deployment.cloud.aws]]
== Amazon Web Services (AWS) == Amazon Web Services (AWS)
Amazon Web Services provides several options that are suitable for running Spring Boot-based applications, either as containers, traditional web applications (war), or self-contained executable jar files. Amazon Web Services offers multiple ways to install Spring Boot-based applications, either as traditional web applications (war) or as executable jar files with an embedded web server.
Popular options are: The options include:
* Amazon Elastic Container Service (ECS)
* AWS Elastic Beanstalk * AWS Elastic Beanstalk
* AWS Code Deploy
* AWS OPS Works
* AWS Cloud Formation
* AWS Container Registry
Each has different features and pricing models.
In this document, we describe to approach using AWS Elastic Beanstalk.
[[howto.deployment.cloud.aws.ecs]]
=== Amazon Elastic Container Service (ECS)
Official https://docs.aws.amazon.com/AmazonECS/latest/developerguide/Welcome.html[Amazon ECS developer guide] provides comprehensive overview of platform's features and includes https://docs.aws.amazon.com/AmazonECS/latest/developerguide/getting-started.html[getting started guide] that walks you through the steps needed to get your containers up and running.
NOTE: Spring Boot applications can be packaged in Docker containers using techniques described in xref:reference:packaging/container-images/index.adoc[].
In addition to the developer guide, AWS also provides a https://docs.aws.amazon.com/AmazonECS/latest/developerguide/create-container-image.html[topical guide] for deploying containerized Java services on Amazon ECS using AWS Fargate.
TIP: Spring Boot auto-detects AWS ECS deployment environments by checking the environment for the `AWS_EXECUTION_ENV` variable.
You can override this detection with the configprop:spring.main.cloud-platform[] configuration property.
@ -286,9 +278,8 @@ You can either use the "`Tomcat Platform`" or the "`Java SE platform`".
==== Using the Tomcat Platform ==== Using the Tomcat Platform
This option applies to Spring Boot projects that produce a war file. This option applies to Spring Boot projects that produce a war file.
Follow the official guide and https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/tomcat-quickstart.html[Java on Tomcat tutorial]. No special configuration is required.
You need only follow the official guide.
NOTE: Creating deployable war for a Spring Boot application is described in xref:deployment/traditional-deployment.adoc[].
@ -296,8 +287,51 @@ NOTE: Creating deployable war for a Spring Boot application is described in xref
==== Using the Java SE Platform ==== Using the Java SE Platform
This option applies to Spring Boot projects that produce a jar file and run an embedded web container. This option applies to Spring Boot projects that produce a jar file and run an embedded web container.
Follow the official guide and https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/java-quickstart.html[Java tutorial]. Elastic Beanstalk environments run an nginx instance on port 80 to proxy the actual application, running on port 5000.
There are also community provided tutorials such as https://www.baeldung.com/spring-boot-deploy-aws-beanstalk[this one]. To configure it, add the following line to your `application.properties` file:
[configprops,yaml]
----
server:
port: 5000
----
[TIP]
.Upload binaries instead of sources
====
By default, Elastic Beanstalk uploads sources and compiles them in AWS.
However, it is best to upload the binaries instead.
To do so, add lines similar to the following to your `.elasticbeanstalk/config.yml` file:
[source,xml]
----
deploy:
artifact: target/demo-0.0.1-SNAPSHOT.jar
----
====
[TIP]
.Reduce costs by setting the environment type
====
By default an Elastic Beanstalk environment is load balanced.
The load balancer has a significant cost.
To avoid that cost, set the environment type to "`Single instance`", as described in https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/environments-create-wizard.html#environments-create-wizard-capacity[the Amazon documentation].
You can also create single instance environments by using the CLI and the following command:
[source]
----
eb create -s
----
====
[[howto.deployment.cloud.aws.summary]]
=== Summary
This is one of the easiest ways to get to AWS, but there are more things to cover, such as how to integrate Elastic Beanstalk into any CI / CD tool, use the Elastic Beanstalk Maven plugin instead of the CLI, and others.
There is a https://exampledriven.wordpress.com/2017/01/09/spring-boot-aws-elastic-beanstalk-example/[blog post] covering these topics more in detail.

View File

@ -33,9 +33,9 @@ apply plugin: 'war'
---- ----
The final step in the process is to ensure that the embedded servlet container does not interfere with the servlet container to which the war file is deployed. The final step in the process is to ensure that the embedded servlet container does not interfere with the servlet container to which the war file is deployed.
To do so, you need to mark the embedded servlet runtime dependency as being provided.
For Maven, you need to mark the embedded servlet container dependency as being `provided`. If you use Maven, the following example marks the servlet runtime (Tomcat, in this case) as being provided:
For example:
[source,xml] [source,xml]
---- ----
@ -43,21 +43,20 @@ For example:
<!-- ... --> <!-- ... -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId> <artifactId>spring-boot-tomcat-runtime</artifactId>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<!-- ... --> <!-- ... -->
</dependencies> </dependencies>
---- ----
If you use Gradle, you need to move only the runtime dependencies into the `providedRuntime` configuration. If you use Gradle, the following example marks the servlet runtime (Tomcat, in this case) as being provided:
For example:
[source,gradle] [source,gradle]
---- ----
dependencies { dependencies {
// ... // ...
providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat-runtime' providedRuntime 'org.springframework.boot:spring-boot-tomcat-runtime'
// ... // ...
} }
---- ----

View File

@ -37,8 +37,6 @@ NOTE: The CNB builder used for the images is `paketobuildpacks/builder-noble-jav
It has a small footprint and reduced attack surface. It does not include a shell and contains a reduced set of system libraries. It has a small footprint and reduced attack surface. It does not include a shell and contains a reduced set of system libraries.
If you need more tools in the resulting image, you can use `paketobuildpacks/ubuntu-noble-run:latest` as the *run* image. If you need more tools in the resulting image, you can use `paketobuildpacks/ubuntu-noble-run:latest` as the *run* image.
NOTE: You have to build your application with at least JDK 25, because Buildpacks use the same GraalVM native-image version as the Java version used for compilation.
[[howto.native-image.developing-your-first-application.buildpacks.system-requirements]] [[howto.native-image.developing-your-first-application.buildpacks.system-requirements]]

View File

@ -247,7 +247,7 @@ For more detail, see the following sections:
Spring Boot installs a '`whitelabel`' error page that you see in a browser client if you encounter a server error (machine clients consuming JSON and other media types should see a sensible response with the right error code). Spring Boot installs a '`whitelabel`' error page that you see in a browser client if you encounter a server error (machine clients consuming JSON and other media types should see a sensible response with the right error code).
NOTE: Set configprop:spring.web.error.whitelabel.enabled[] to `false` to switch the default error page off. NOTE: Set `server.error.whitelabel.enabled=false` to switch the default error page off.
Doing so restores the default of the servlet container that you are using. Doing so restores the default of the servlet container that you are using.
Note that Spring Boot still tries to resolve the error view, so you should probably add your own error page rather than disabling it completely. Note that Spring Boot still tries to resolve the error view, so you should probably add your own error page rather than disabling it completely.

View File

@ -18,18 +18,13 @@ Many Spring Boot starters include default embedded containers.
When switching to a different HTTP server, you need to swap the default dependencies for those that you need instead. When switching to a different HTTP server, you need to swap the default dependencies for those that you need instead.
To help with this process, Spring Boot provides a separate starter for each of the supported HTTP servers. To help with this process, Spring Boot provides a separate starter for each of the supported HTTP servers.
The following example shows how to exclude Tomcat and include Jetty for Spring MVC: The following Maven example shows how to exclude Tomcat and include Jetty for Spring MVC:
[tabs]
======
Maven::
+
[source,xml] [source,xml]
---- ----
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId> <artifactId>spring-boot-starter-web</artifactId>
<exclusions> <exclusions>
<!-- Exclude the Tomcat dependency --> <!-- Exclude the Tomcat dependency -->
<exclusion> <exclusion>
@ -44,70 +39,23 @@ Maven::
<artifactId>spring-boot-starter-jetty</artifactId> <artifactId>spring-boot-starter-jetty</artifactId>
</dependency> </dependency>
---- ----
+
Gradle:: The following Gradle example configures the necessary dependencies and a {url-gradle-docs}/resolution_rules.html#sec:module_replacement[module replacement] to use Tomcat in place of Reactor Netty for Spring WebFlux:
+
[source,gradle] [source,gradle]
---- ----
dependencies { dependencies {
implementation('org.springframework.boot:spring-boot-starter-webmvc') { implementation "org.springframework.boot:spring-boot-starter-tomcat"
// Exclude the Tomcat dependency implementation "org.springframework.boot:spring-boot-starter-webflux"
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' modules {
module("org.springframework.boot:spring-boot-starter-reactor-netty") {
replacedBy("org.springframework.boot:spring-boot-starter-tomcat", "Use Tomcat instead of Reactor Netty")
}
} }
// Use Jetty instead
implementation "org.springframework.boot:spring-boot-starter-jetty"
} }
---- ----
+
======
If you are creating a war file, you can use a similar approach, but you must indicate provided dependencies: NOTE: `spring-boot-starter-reactor-netty` is required to use the javadoc:org.springframework.web.reactive.function.client.WebClient[] class, so you may need to keep a dependency on Netty even when you need to include a different HTTP server.
[tabs]
======
Maven::
+
[source,xml]
----
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
<exclusions>
<!-- Exclude the Tomcat dependency -->
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Use Jetty instead -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jetty</artifactId>
<scope>provided</scope>
</dependency>
----
+
Gradle::
+
[source,gradle]
----
dependencies {
implementation('org.springframework.boot:spring-boot-starter-webmvc') {
// Exclude the Tomcat dependency
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
}
// Use Jetty instead
implementation "org.springframework.boot:spring-boot-starter-jetty"
providedRuntime "org.springframework.boot:spring-boot-starter-jetty-runtime"
}
----
+
======

View File

@ -383,17 +383,6 @@ management:
TIP: See javadoc:org.springframework.boot.actuate.autoconfigure.endpoint.web.CorsEndpointProperties[] for a complete list of options. TIP: See javadoc:org.springframework.boot.actuate.autoconfigure.endpoint.web.CorsEndpointProperties[] for a complete list of options.
[[actuator.endpoints.json]]
== JSON
When working with JSON, Jackson is used for serialization and deserialization.
By default, an isolated javadoc:tools.jackson.databind.json.JsonMapper[] is used.
This isolation means that it does not share the same configuration as the application's `JsonMapper` and it is not affected by `spring.jackson.*` properties.
To disable this behavior and configure Actuator to use the application's `JsonMapper`, set configprop:management.endpoints.jackson.isolated-json-mapper[] to `false`.
Alternatively, you can define your own javadoc:org.springframework.boot.actuate.endpoint.jackson.EndpointJsonMapper[] bean that produces a `JsonMapper` that meets your needs.
Actuator will then use it for JSON processing.
[[actuator.endpoints.implementing-custom]] [[actuator.endpoints.implementing-custom]]
== Implementing Custom Endpoints == Implementing Custom Endpoints
@ -685,8 +674,8 @@ with the `key` listed in the following table:
TIP: You can disable them all by setting the configprop:management.health.defaults.enabled[] property. TIP: You can disable them all by setting the configprop:management.health.defaults.enabled[] property.
TIP: The `ssl` javadoc:org.springframework.boot.actuate.health.HealthIndicator[] has a "warning threshold" property named configprop:management.health.ssl.certificate-validity-warning-threshold[]. TIP: The `ssl` javadoc:org.springframework.boot.actuate.health.HealthIndicator[] has a "warning threshold" property named configprop:management.health.ssl.certificate-validity-warning-threshold[].
You can use this threshold to give yourself enough lead time to rotate the soon-to-be-expired certificate. If an SSL certificate will be invalid within the time span defined by this threshold, the javadoc:org.springframework.boot.actuate.health.HealthIndicator[] will warn you but it will still return HTTP 200 to not disrupt the application.
If an SSL certificate will become invalid within the period defined by this threshold, the javadoc:org.springframework.boot.actuate.health.HealthIndicator[] will report this in the details section of its response where `details.validChains.certificates.[*].validity.status` will have the value `WILL_EXPIRE_SOON`. You can use this threshold to give yourself enough lead time to rotate the soon to be expired certificate.
Additional javadoc:org.springframework.boot.actuate.health.HealthIndicator[] beans are enabled by default: Additional javadoc:org.springframework.boot.actuate.health.HealthIndicator[] beans are enabled by default:
@ -1271,7 +1260,7 @@ The `info` endpoint publishes information about your process, see javadoc:org.sp
[[actuator.endpoints.info.ssl-information]] [[actuator.endpoints.info.ssl-information]]
=== SSL Information === SSL Information
The `info` endpoint publishes information about your SSL certificates (that are configured through xref:features/ssl.adoc#features.ssl.bundles[SSL Bundles]), see javadoc:org.springframework.boot.info.SslInfo[] for more details. The `info` endpoint publishes information about your SSL certificates (that are configured through xref:features/ssl.adoc#features.ssl.bundles[SSL Bundles]), see javadoc:org.springframework.boot.info.SslInfo[] for more details. This endpoint reuses the "warning threshold" property of javadoc:org.springframework.boot.health.info.SslHealthIndicator[]: if an SSL certificate will be invalid within the time span defined by this threshold, it will trigger a warning. See the `management.health.ssl.certificate-validity-warning-threshold` property.

View File

@ -29,11 +29,6 @@ Observability support relies on the https://github.com/micrometer-metrics/contex
By default, javadoc:java.lang.ThreadLocal[] values are not automatically reinstated in reactive operators. By default, javadoc:java.lang.ThreadLocal[] values are not automatically reinstated in reactive operators.
This behavior is controlled with the configprop:spring.reactor.context-propagation[] property, which can be set to `auto` to enable automatic propagation. This behavior is controlled with the configprop:spring.reactor.context-propagation[] property, which can be set to `auto` to enable automatic propagation.
If you're working with javadoc:org.springframework.scheduling.annotation.Async[format=annotation] methods or use an javadoc:org.springframework.core.task.AsyncTaskExecutor[], you have to register the javadoc:org.springframework.core.task.support.ContextPropagatingTaskDecorator[] on the executor, otherwise the observability context is lost when switching threads.
This can be done using this configuration:
include-code::ContextPropagationConfiguration[]
For more details about observations please see the {url-micrometer-docs}/observation[Micrometer Observation documentation]. For more details about observations please see the {url-micrometer-docs}/observation[Micrometer Observation documentation].
@ -87,18 +82,6 @@ The preceding example will prevent all observations whose name contains "denied"
[[actuator.observability.annotations]]
== Micrometer Observation Annotations support
To enable scanning of observability annotations like javadoc:io.micrometer.observation.annotation.Observed[format=annotation], javadoc:io.micrometer.core.annotation.Timed[format=annotation], javadoc:io.micrometer.core.annotation.Counted[format=annotation], javadoc:io.micrometer.core.aop.MeterTag[format=annotation] and javadoc:io.micrometer.tracing.annotation.NewSpan[format=annotation], you need to set the configprop:management.observations.annotations.enabled[] property to `true`.
This feature is supported by Micrometer directly.
Please refer to the {url-micrometer-docs-concepts}/timers.html#_the_timed_annotation[Micrometer], {url-micrometer-docs-observation}/components.html#micrometer-observation-annotations[Micrometer Observation] and {url-micrometer-tracing-docs}/api.html#_aspect_oriented_programming[Micrometer Tracing] reference docs.
NOTE: When you annotate methods or classes which are already instrumented (for example, xref:reference:actuator/metrics.adoc#actuator.metrics.supported.spring-data-repository[Spring Data repositories] or xref:reference:actuator/metrics.adoc#actuator.metrics.supported.spring-mvc[Spring MVC controllers]), you will get duplicate observations.
In that case you can either disable the automatic instrumentation using xref:reference:actuator/observability.adoc#actuator.observability.preventing-observations[properties] or an javadoc:io.micrometer.observation.ObservationPredicate[] and rely on your annotations, or you can remove your annotations.
[[actuator.observability.opentelemetry]] [[actuator.observability.opentelemetry]]
== OpenTelemetry Support == OpenTelemetry Support
@ -118,82 +101,24 @@ Auto-configured attributes will be merged with attributes from the `OTEL_RESOURC
If you have defined your own javadoc:io.opentelemetry.sdk.resources.Resource[] bean, this will no longer be the case. If you have defined your own javadoc:io.opentelemetry.sdk.resources.Resource[] bean, this will no longer be the case.
NOTE: Spring Boot does not provide automatic exporting of OpenTelemetry metrics or logs. NOTE: Spring Boot does not provide auto-configuration for OpenTelemetry metrics or logging.
Exporting OpenTelemetry traces is only auto-configured when used together with xref:actuator/tracing.adoc[Micrometer Tracing]. OpenTelemetry tracing is only auto-configured when used together with xref:actuator/tracing.adoc[Micrometer Tracing].
[[actuator.observability.opentelemetry.environment-variables]]
=== Environment variables
Spring Boot supports the following environment variables to configure the OpenTelemetry resource:
* https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#general-sdk-configuration[`OTEL_RESOURCE_ATTRIBUTES`]
* https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#general-sdk-configuration[`OTEL_SERVICE_NAME`]
NOTE: The `OTEL_RESOURCE_ATTRIBUTES` environment variable consists of a list of key-value pairs. NOTE: The `OTEL_RESOURCE_ATTRIBUTES` environment variable consists of a list of key-value pairs.
For example: `key1=value1,key2=value2,key3=spring%20boot`. For example: `key1=value1,key2=value2,key3=spring%20boot`.
All attribute values are treated as strings, and any characters outside the baggage-octet range must be **percent-encoded**. All attribute values are treated as strings, and any characters outside the baggage-octet range must be **percent-encoded**.
Micrometer also supports the following environment variables to configure the metrics export over OTLP:
* https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/#otel_exporter_otlp_endpoint[`OTEL_EXPORTER_OTLP_ENDPOINT`] The next sections will provide more details about logging, metrics and traces.
* https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/#otel_exporter_otlp_metrics_endpoint[`OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`]
* https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/#otel_exporter_otlp_headers[`OTEL_EXPORTER_OTLP_HEADERS`]
* https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/#otel_exporter_otlp_metrics_headers[`OTEL_EXPORTER_OTLP_METRICS_HEADERS`]
Other environment variables as described in https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/[the OpenTelemetry documentation] are not supported.
If you want all environment variables specified by OpenTelemetry's SDK to be effective, you have to supply your own `OpenTelemetry` bean.
WARNING: Doing this will switch off Spring Boot's OpenTelemetry auto-configuration and may break the built-in observability functionality.
First, add a dependency to `io.opentelemetry:opentelemetry-sdk-extension-autoconfigure` to get https://opentelemetry.io/docs/languages/java/configuration/#zero-code-sdk-autoconfigure[OpenTelemetry's zero-code SDK autoconfigure module], then add this configuration:
include-code::AutoConfiguredOpenTelemetrySdkConfiguration[]
[[actuator.observability.opentelemetry.logging]] [[actuator.observability.annotations]]
=== Logging == Micrometer Observation Annotations support
The javadoc:org.springframework.boot.opentelemetry.autoconfigure.logging.OpenTelemetryLoggingAutoConfiguration[] configures OpenTelemetry's javadoc:io.opentelemetry.sdk.logs.SdkLoggerProvider[]. To enable scanning of observability annotations like javadoc:io.micrometer.observation.annotation.Observed[format=annotation], javadoc:io.micrometer.core.annotation.Timed[format=annotation], javadoc:io.micrometer.core.annotation.Counted[format=annotation], javadoc:io.micrometer.core.aop.MeterTag[format=annotation] and javadoc:io.micrometer.tracing.annotation.NewSpan[format=annotation], you need to set the configprop:management.observations.annotations.enabled[] property to `true`.
Exporting logs via OTLP is supported through the javadoc:org.springframework.boot.opentelemetry.autoconfigure.logging.otlp.OtlpLoggingAutoConfiguration[], which enables OTLP log exporting over HTTP or gRPC. This feature is supported by Micrometer directly.
Please refer to the {url-micrometer-docs-concepts}/timers.html#_the_timed_annotation[Micrometer], {url-micrometer-docs-observation}/components.html#micrometer-observation-annotations[Micrometer Observation] and {url-micrometer-tracing-docs}/api.html#_aspect_oriented_programming[Micrometer Tracing] reference docs.
However, while there is a `SdkLoggerProvider` bean, Spring Boot doesn't support bridging logs to this bean out of the box. NOTE: When you annotate methods or classes which are already instrumented (for example, xref:reference:actuator/metrics.adoc#actuator.metrics.supported.spring-data-repository[Spring Data repositories] or xref:reference:actuator/metrics.adoc#actuator.metrics.supported.spring-mvc[Spring MVC controllers]), you will get duplicate observations.
This can be done with 3rd-party log bridges, as described in the xref:reference:actuator/loggers.adoc#actuator.loggers.opentelemetry[Logging with OpenTelemetry] section. In that case you can either disable the automatic instrumentation using xref:reference:actuator/observability.adoc#actuator.observability.preventing-observations[properties] or an javadoc:io.micrometer.observation.ObservationPredicate[] and rely on your annotations, or you can remove your annotations.
[[actuator.observability.opentelemetry.metrics]]
=== Metrics
The choice of metrics in the Spring portfolio is Micrometer, which means that metrics are not collected and exported through the OpenTelemetry's javadoc:io.opentelemetry.sdk.metrics.SdkMeterProvider[].
Spring Boot doesn't provide a `SdkMeterProvider` bean.
However, Micrometer metrics can be exported via OTLP to any OpenTelemetry capable backend using the javadoc:io.micrometer.registry.otlp.OtlpMeterRegistry[], as described in the xref:reference:actuator/metrics.adoc#actuator.metrics.export.otlp[Metrics with OTLP] section.
NOTE: Micrometer's OTLP registry doesn't use the `Resource` bean, but setting `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_SERVICE_NAME` or configprop:management.opentelemetry.resource-attributes[] works.
[[actuator.observability.opentelemetry.metrics.api-and-sdk]]
==== Metrics via the OpenTelemetry API and SDK
If you or a dependency you include make use of OpenTelemetry's javadoc:io.opentelemetry.api.metrics.MeterProvider[], those metrics are not exported.
We strongly recommend that you report your metrics with Micrometer.
If a dependency you include uses OpenTelemetry's `MeterProvider`, you can include this configuration in your application to configure a `MeterProvider` bean, which you then have to wire into your dependency:
include-code::OpenTelemetryMetricsConfiguration[]
This configuration also enables metrics export via OTLP over HTTP.
[[actuator.observability.opentelemetry.tracing]]
=== Tracing
If Micrometer tracing is used, the javadoc:org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.OpenTelemetryTracingAutoConfiguration[] configures OpenTelemetry's javadoc:io.opentelemetry.sdk.trace.SdkTracerProvider[].
Exporting traces through OTLP is enabled by the javadoc:org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.otlp.OtlpTracingAutoConfiguration[], which supports exporting traces with OTLP over HTTP or gRPC.
We strongly recommend using the Micrometer Observation or Tracing API instead of using the OpenTelemetry API directly.

View File

@ -341,7 +341,7 @@ To take full control over the client's configuration, define a javadoc:co.elasti
Additionally, a javadoc:co.elastic.clients.transport.rest5_client.low_level.sniffer.Sniffer[] can be auto-configured to automatically discover nodes from a running Elasticsearch cluster and set them on the javadoc:co.elastic.clients.transport.rest5_client.low_level.Rest5Client[] bean. Additionally, a javadoc:co.elastic.clients.transport.rest5_client.low_level.sniffer.Sniffer[] is auto-configured to automatically discover nodes from a running Elasticsearch cluster and set them on the javadoc:co.elastic.clients.transport.rest5_client.low_level.Rest5Client[] bean.
You can further tune how javadoc:co.elastic.clients.transport.rest5_client.low_level.sniffer.Sniffer[] is configured, as shown in the following example: You can further tune how javadoc:co.elastic.clients.transport.rest5_client.low_level.sniffer.Sniffer[] is configured, as shown in the following example:
[configprops,yaml] [configprops,yaml]
@ -350,17 +350,18 @@ spring:
elasticsearch: elasticsearch:
restclient: restclient:
sniffer: sniffer:
enabled: true
interval: "10m" interval: "10m"
delay-after-failure: "30s" delay-after-failure: "30s"
---- ----
To disable auto-configuration of the Sniffer, set configprop:spring.elasticsearch.restclient.sniffer.enabled[] to `false`.
[[data.nosql.elasticsearch.connecting-using-rest.javaapiclient]] [[data.nosql.elasticsearch.connecting-using-rest.javaapiclient]]
==== Connecting to Elasticsearch Using ElasticsearchClient ==== Connecting to Elasticsearch Using ElasticsearchClient
If you use the `spring-boot-starter-elasticsearch` or have added `co.elastic.clients:elasticsearch-java` to the classpath, Spring Boot will auto-configure and register an javadoc:co.elastic.clients.elasticsearch.ElasticsearchClient[] bean. If you have `co.elastic.clients:elasticsearch-java` on the classpath, Spring Boot will auto-configure and register an javadoc:co.elastic.clients.elasticsearch.ElasticsearchClient[] bean.
The javadoc:co.elastic.clients.elasticsearch.ElasticsearchClient[] uses a transport that depends upon the previously described javadoc:co.elastic.clients.transport.rest5_client.low_level.Rest5Client[]. The javadoc:co.elastic.clients.elasticsearch.ElasticsearchClient[] uses a transport that depends upon the previously described javadoc:co.elastic.clients.transport.rest5_client.low_level.Rest5Client[].
Therefore, the properties described previously can be used to configure the javadoc:co.elastic.clients.elasticsearch.ElasticsearchClient[]. Therefore, the properties described previously can be used to configure the javadoc:co.elastic.clients.elasticsearch.ElasticsearchClient[].

View File

@ -795,8 +795,12 @@ NOTE: The properties that map to javadoc:org.springframework.boot.context.proper
Such arrangement relies on a default empty constructor and getters and setters are usually mandatory, since binding is through standard Java Beans property descriptors, just like in Spring MVC. Such arrangement relies on a default empty constructor and getters and setters are usually mandatory, since binding is through standard Java Beans property descriptors, just like in Spring MVC.
A setter may be omitted in the following cases: A setter may be omitted in the following cases:
* Pre-initialized Maps and Collections, as long as they are initialized with a mutable implementation (like the `roles` field in the preceding example). * Maps, as long as they are initialized, need a getter but not necessarily a setter, since they can be mutated by the binder.
* Pre-initialized nested POJOs (like the `Security` field in the preceding example). * Collections and arrays can be accessed either through an index (typically with YAML) or by using a single comma-separated value (properties).
In the latter case, a setter is mandatory.
We recommend to always add a setter for such types.
If you initialize a collection, make sure it is not immutable (as in the preceding example).
* If nested POJO properties are initialized (like the `Security` field in the preceding example), a setter is not required.
If you want the binder to create the instance on the fly by using its default constructor, you need a setter. If you want the binder to create the instance on the fly by using its default constructor, you need a setter.
Some people use Project Lombok to add getters and setters automatically. Some people use Project Lombok to add getters and setters automatically.

View File

@ -101,4 +101,4 @@ The preferred JSON-B implementation is Eclipse Yasson for which dependency manag
Auto-configuration for Kotlin Serialization is provided. Auto-configuration for Kotlin Serialization is provided.
When `kotlinx-serialization-json` is on the classpath a https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json/[Json] bean is automatically configured. When `kotlinx-serialization-json` is on the classpath a https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json/[Json] bean is automatically configured.
Several `+spring.kotlinx.serialization.json.*+` configuration properties are provided for customizing the configuration. Several `+spring.kotlin.serialization.*+` configuration properties are provided for customizing the configuration.

View File

@ -142,7 +142,10 @@ spring:
Caches can be created on startup by setting the configprop:spring.cache.cache-names[] property. Caches can be created on startup by setting the configprop:spring.cache.cache-names[] property.
If a custom javadoc:org.infinispan.configuration.cache.ConfigurationBuilder[] bean is defined, it is used to customize the caches. If a custom javadoc:org.infinispan.configuration.cache.ConfigurationBuilder[] bean is defined, it is used to customize the caches.
For more details, see https://infinispan.org/docs/stable/titles/spring/spring.html[the documentation]. To be compatible with Spring Boot's Jakarta EE 9 baseline, Infinispan's `-jakarta` modules must be used.
For every module with a `-jakarta` variant, the variant must be used in place of the standard module.
For example, `infinispan-core-jakarta` and `infinispan-commons-jakarta` must be used in place of `infinispan-core` and `infinispan-commons` respectively.
[[io.caching.provider.couchbase]] [[io.caching.provider.couchbase]]

View File

@ -56,7 +56,6 @@ NOTE: Your executable jar must include AOT generated assets such as generated cl
Spring Boot applications usually use Cloud Native Buildpacks through the Maven (`mvn spring-boot:build-image`) or Gradle (`gradle bootBuildImage`) integrations. Spring Boot applications usually use Cloud Native Buildpacks through the Maven (`mvn spring-boot:build-image`) or Gradle (`gradle bootBuildImage`) integrations.
You can, however, also use {url-buildpacks-docs}/for-platform-operators/how-to/integrate-ci/pack/[`pack`] to turn an AOT processed Spring Boot executable jar into a native container image. You can, however, also use {url-buildpacks-docs}/for-platform-operators/how-to/integrate-ci/pack/[`pack`] to turn an AOT processed Spring Boot executable jar into a native container image.
NOTE: You have to build your application with at least JDK 25, because Buildpacks use the same GraalVM native-image version as the Java version used for compilation.
First, make sure that a Docker daemon is available (see https://docs.docker.com/installation/#installation[Get Docker] for more details). First, make sure that a Docker daemon is available (see https://docs.docker.com/installation/#installation[Get Docker] for more details).
https://docs.docker.com/engine/install/linux-postinstall/#manage-docker-as-a-non-root-user[Configure it to allow non-root user] if you are on Linux. https://docs.docker.com/engine/install/linux-postinstall/#manage-docker-as-a-non-root-user[Configure it to allow non-root user] if you are on Linux.

View File

@ -325,7 +325,7 @@ Usually, you would define the properties in your `application.properties` or `ap
Common server settings include: Common server settings include:
* Network settings: Listen port for incoming HTTP requests (`server.port`), interface address to bind to (`server.address`), and so on. * Network settings: Listen port for incoming HTTP requests (`server.port`), interface address to bind to (`server.address`), and so on.
* Error management: Location of the error page (configprop:spring.web.error.path[]) and so on. * Error management: Location of the error page (`server.error.path`) and so on.
* xref:how-to:webserver.adoc#howto.webserver.configure-ssl[SSL] * xref:how-to:webserver.adoc#howto.webserver.configure-ssl[SSL]
* xref:how-to:webserver.adoc#howto.webserver.enable-response-compression[HTTP compression] * xref:how-to:webserver.adoc#howto.webserver.enable-response-compression[HTTP compression]

View File

@ -589,14 +589,6 @@ The single `onStartup` method provides access to the javadoc:jakarta.servlet.Ser
[[web.servlet.embedded-container.context-initializer.init-parameters]]
==== Init Parameters
Init parameters can be configured on the javadoc:jakarta.servlet.ServletContext[] using `server.servlet.context-parameters.*` properties.
For example, the property `server.servlet.context-parameters.com.example.parameter=example` will configure a `ServletContext` init parameter named `com.example.parameter` with the value `example`.
[[web.servlet.embedded-container.context-initializer.scanning]] [[web.servlet.embedded-container.context-initializer.scanning]]
==== Scanning for Servlets, Filters, and listeners ==== Scanning for Servlets, Filters, and listeners
@ -636,7 +628,7 @@ Common server settings include:
* Network settings: Listen port for incoming HTTP requests (`server.port`), interface address to bind to (`server.address`), and so on. * Network settings: Listen port for incoming HTTP requests (`server.port`), interface address to bind to (`server.address`), and so on.
* Session settings: Whether the session is persistent (`server.servlet.session.persistent`), session timeout (`server.servlet.session.timeout`), location of session data (`server.servlet.session.store-dir`), and session-cookie configuration (`server.servlet.session.cookie.*`). * Session settings: Whether the session is persistent (`server.servlet.session.persistent`), session timeout (`server.servlet.session.timeout`), location of session data (`server.servlet.session.store-dir`), and session-cookie configuration (`server.servlet.session.cookie.*`).
* Error management: Location of the error page (configprop:spring.web.error.path[]) and so on. * Error management: Location of the error page (`server.error.path`) and so on.
* xref:how-to:webserver.adoc#howto.webserver.configure-ssl[SSL] * xref:how-to:webserver.adoc#howto.webserver.configure-ssl[SSL]
* xref:how-to:webserver.adoc#howto.webserver.enable-response-compression[HTTP compression] * xref:how-to:webserver.adoc#howto.webserver.enable-response-compression[HTTP compression]

View File

@ -1,31 +0,0 @@
/*
* Copyright 2012-present 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.docs.actuator.observability.contextpropagation;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
@Configuration(proxyBeanMethods = false)
class ContextPropagationConfiguration {
@Bean
ContextPropagatingTaskDecorator contextPropagatingTaskDecorator() {
return new ContextPropagatingTaskDecorator();
}
}

View File

@ -1,33 +0,0 @@
/*
* Copyright 2012-present 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.docs.actuator.observability.opentelemetry.environmentvariables;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
class AutoConfiguredOpenTelemetrySdkConfiguration {
@Bean
OpenTelemetry autoConfiguredOpenTelemetrySdk() {
return AutoConfiguredOpenTelemetrySdk.initialize().getOpenTelemetrySdk();
}
}

View File

@ -1,51 +0,0 @@
/*
* Copyright 2012-present 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.docs.actuator.observability.opentelemetry.metrics.apiandsdk;
import java.time.Duration;
import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter;
import io.opentelemetry.sdk.metrics.SdkMeterProvider;
import io.opentelemetry.sdk.metrics.export.MetricExporter;
import io.opentelemetry.sdk.metrics.export.MetricReader;
import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader;
import io.opentelemetry.sdk.resources.Resource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
class OpenTelemetryMetricsConfiguration {
@Bean
OtlpHttpMetricExporter metricExporter() {
String endpoint = "http://localhost:4318/v1/metrics";
return OtlpHttpMetricExporter.builder().setEndpoint(endpoint).build();
}
@Bean
PeriodicMetricReader metricReader(MetricExporter exporter) {
Duration interval = Duration.ofMinutes(1);
return PeriodicMetricReader.builder(exporter).setInterval(interval).build();
}
@Bean
SdkMeterProvider meterProvider(Resource resource, MetricReader metricReader) {
return SdkMeterProvider.builder().registerMetricReader(metricReader).setResource(resource).build();
}
}

View File

@ -33,7 +33,7 @@ public class MyProperties {
} }
@Deprecated @Deprecated
@DeprecatedConfigurationProperty(replacement = "my.app.name", since = "1.2.0") @DeprecatedConfigurationProperty(replacement = "my.app.name")
public String getTarget() { public String getTarget() {
return this.name; return this.name;
} }

View File

@ -30,11 +30,11 @@ public final class MyAutoConfiguration {
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
@ConditionalOnClass(SomeService.class) @ConditionalOnClass(SomeService.class)
static class SomeServiceConfiguration { public static class SomeServiceConfiguration {
@Bean @Bean
@ConditionalOnMissingBean @ConditionalOnMissingBean
SomeService someService() { public SomeService someService() {
return new SomeService(); return new SomeService();
} }

Some files were not shown because too many files have changed in this diff Show More