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!
uses: ./.github/actions/publish-to-sdkman
with:
make-default: true
make-default: false
sdkman-consumer-key: ${{ secrets.SDKMAN_CONSUMER_KEY }}
sdkman-consumer-token: ${{ secrets.SDKMAN_CONSUMER_TOKEN }}
spring-boot-version: ${{ needs.build-and-stage-release.outputs.version }}

1846
antora/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,20 @@
{
"scripts": {
"antora": "node npm/antora.js"
"antora": "node npm/antora.js",
"postinstall": "patch-package"
},
"dependencies": {
"@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",
"@springio/antora-extensions": "1.14.7",
"@springio/antora-xref-extension": "1.0.0-alpha.5",
"@springio/antora-zip-contents-collector-extension": "1.0.0-alpha.10",
"@springio/antora-xref-extension": "1.0.0-alpha.4",
"@springio/antora-zip-contents-collector-extension": "1.0.0-alpha.8",
"@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": {
"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" />
<resolvers>
<chain name="chain">
<!-- NOTE: You should declare only repositories that you need here -->
<filesystem name="local" local="true" m2compatible="true">
<artifact pattern="${user.home}/.m2/[organisation]/[module]/[revision]/[module]-[revision].[ext]" />
<ivy pattern="${user.home}/.m2/[organisation]/[module]/[revision]/[module]-[revision].pom" />
@ -10,5 +11,6 @@
<!-- {spring.mavenRepositories} -->
</chain>
</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>

View File

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

View File

@ -8,6 +8,6 @@ apply(plugin = "io.spring.dependency-management")
// tag::dependencies[]
dependencies {
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[]

View File

@ -27,7 +27,7 @@ The `assemble` task is automatically configured to depend upon the `bootWar` tas
=== 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.
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]
======
@ -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.

View File

@ -86,8 +86,6 @@ class WarPluginAction implements PluginApplicationAction {
.minus((developmentOnly.minus(productionRuntimeClasspath)))
.minus((testAndDevelopmentOnly.minus(productionRuntimeClasspath)))
.filter(new JarTypeFileSpec());
Callable<FileCollection> providedClasspath = () -> providedRuntimeConfiguration(project)
.filter(new JarTypeFileSpec());
TaskProvider<ResolveMainClassName> resolveMainClassName = project.getTasks()
.named(SpringBootPlugin.RESOLVE_MAIN_CLASS_NAME_TASK_NAME, ResolveMainClassName.class);
TaskProvider<BootWar> bootWarProvider = project.getTasks()
@ -95,7 +93,7 @@ class WarPluginAction implements PluginApplicationAction {
bootWar.setGroup(BasePlugin.BUILD_GROUP);
bootWar.setDescription("Assembles an executable war archive containing webapp"
+ " content, and the main classes and their dependencies.");
bootWar.providedClasspath(providedClasspath);
bootWar.providedClasspath(providedRuntimeConfiguration(project));
bootWar.setClasspath(classpath);
Provider<String> manifestStartClass = project
.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.StreamUtils;
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).
@ -326,7 +325,7 @@ class BootZipCopyAction implements CopyAction {
private void writeJarModeLibrary(String location, JarModeLibrary library) throws IOException {
String name = location + library.getName();
writeEntry(name, ZipEntryContentWriter.fromInputStream(library.openStream()), false,
(entry) -> prepareStoredEntry(library::openStream, false, entry));
(entry) -> prepareStoredEntry(library.openStream(), false, entry));
if (BootZipCopyAction.this.layerResolver != null) {
Layer layer = BootZipCopyAction.this.layerResolver.getLayer(library);
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 {
prepareStoredEntry(details::open, BootZipCopyAction.this.requiresUnpack.isSatisfiedBy(details),
prepareStoredEntry(details.open(), BootZipCopyAction.this.requiresUnpack.isSatisfiedBy(details),
archiveEntry);
}
private void prepareStoredEntry(ThrowingSupplier<InputStream> input, boolean unpack,
ZipArchiveEntry archiveEntry) throws IOException {
private void prepareStoredEntry(InputStream input, boolean unpack, ZipArchiveEntry archiveEntry)
throws IOException {
new StoredEntryPreparator(input, unpack).prepareStoredEntry(archiveEntry);
}
@ -565,10 +564,10 @@ class BootZipCopyAction implements CopyAction {
private long size;
StoredEntryPreparator(ThrowingSupplier<InputStream> input, boolean unpack) throws IOException {
StoredEntryPreparator(InputStream inputStream, boolean unpack) throws IOException {
this.unpack = unpack;
try (InputStream stream = input.get()) {
load(stream);
try (inputStream) {
load(inputStream);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -20,9 +20,7 @@ import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import java.util.function.Function;
@ -39,8 +37,6 @@ import org.gradle.api.artifacts.repositories.MavenArtifactRepository;
*/
public class RepositoryTransformersExtension {
private static final String CREDENTIALS_MARKER = "{spring.mavenCredentials}";
private static final String REPOSITORIES_MARKER = "{spring.mavenRepositories}";
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);
});
}
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;
}
@ -116,14 +99,10 @@ public class RepositoryTransformersExtension {
}
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();
String indent = getIndent(line);
iterable.forEach((item) -> {
String fragment = generator.apply(item, indent);
getSpringRepositories().forEach((repository) -> {
String fragment = generator.apply(repository, indent);
if (fragment != null) {
result.append(!result.isEmpty() ? "\n" : "");
result.append(fragment);
@ -157,8 +136,4 @@ public class RepositoryTransformersExtension {
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.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.function.Supplier;
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.FileTree;
import org.gradle.api.provider.ListProperty;
import org.gradle.api.provider.MapProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.provider.Provider;
import org.gradle.api.provider.SetProperty;
@ -74,27 +72,19 @@ import org.gradle.api.tasks.VerificationException;
*/
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 DEPRECATED_CONFIGURATION_PROPERTY_ANNOTATION = "org.springframework.boot.context.properties.DeprecatedConfigurationProperty";
private FileCollection classes;
public ArchitectureCheck() {
getOutputDirectory().convention(getProject().getLayout().getBuildDirectory().dir(getName()));
getAnnotationClasses().convention(Map.of(CONDITIONAL_ON_CLASS, CONDITIONAL_ON_CLASS_ANNOTATION,
DEPRECATED_CONFIGURATION_PROPERTY, DEPRECATED_CONFIGURATION_PROPERTY_ANNOTATION));
getConditionalOnClassAnnotation().convention(CONDITIONAL_ON_CLASS_ANNOTATION);
getRules().addAll(getProhibitObjectsRequireNonNull().convention(true)
.map(whenTrue(ArchitectureRules::noClassesShouldCallObjectsRequireNonNull)));
getRules().addAll(ArchitectureRules.standard());
getRules().addAll(whenMainSources(() -> ArchitectureRules
.beanMethods(annotationClassFor(CONDITIONAL_ON_CLASS, CONDITIONAL_ON_CLASS_ANNOTATION))));
getRules().addAll(whenMainSources(() -> ArchitectureRules.configurationProperties(
annotationClassFor(DEPRECATED_CONFIGURATION_PROPERTY, DEPRECATED_CONFIGURATION_PROPERTY_ANNOTATION))));
getRules().addAll(whenMainSources(() -> List
.of(ArchitectureRules.allBeanMethodsShouldReturnNonPrivateType(), ArchitectureRules
.allBeanMethodsShouldNotHaveConditionalOnClassAnnotation(getConditionalOnClassAnnotation().get()))));
getRules().addAll(and(getNullMarkedEnabled(), isMainSourceSet()).map(whenTrue(() -> Collections.singletonList(
ArchitectureRules.packagesShouldBeAnnotatedWithNullMarked(getNullMarkedIgnoredPackages().get())))));
getRuleDescriptions().set(getRules().map(this::asDescriptions));
@ -120,10 +110,6 @@ public abstract class ArchitectureCheck extends DefaultTask {
return rules.stream().map(ArchRule::getDescription).toList();
}
private String annotationClassFor(String name, String defaultValue) {
return getAnnotationClasses().get().getOrDefault(name, defaultValue);
}
@TaskAction
void checkArchitecture() throws Exception {
withCompileClasspath(() -> {
@ -223,7 +209,7 @@ public abstract class ArchitectureCheck extends DefaultTask {
@Internal
abstract SetProperty<String> getNullMarkedIgnoredPackages();
@Input
abstract MapProperty<String, String> getAnnotationClasses();
@Internal
abstract Property<String> getConditionalOnClassAnnotation();
}

View File

@ -16,17 +16,17 @@
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.Project;
import org.gradle.api.Task;
import org.gradle.api.plugins.JavaPlugin;
import org.gradle.api.plugins.JavaPluginExtension;
import org.gradle.api.tasks.SourceSet;
import org.gradle.api.tasks.TaskProvider;
import org.gradle.api.tasks.compile.JavaCompile;
import org.gradle.language.base.plugins.LifecycleBasePlugin;
import org.jetbrains.kotlin.gradle.tasks.KotlinCompileTool;
import org.springframework.util.StringUtils;
@ -46,45 +46,28 @@ public class ArchitecturePlugin implements Plugin<Project> {
private void registerTasks(Project project, ArchitectureCheckExtension extension) {
JavaPluginExtension javaPluginExtension = project.getExtensions().getByType(JavaPluginExtension.class);
List<TaskProvider<ArchitectureCheck>> packageTangleChecks = new ArrayList<>();
for (SourceSet sourceSet : javaPluginExtension.getSourceSets()) {
registerArchitectureCheck(sourceSet, "java", project).configure((task) -> {
task.setClasses(project.files(project.getTasks()
.named(sourceSet.getCompileTaskName("java"), JavaCompile.class)
.flatMap((compile) -> compile.getDestinationDirectory())));
TaskProvider<ArchitectureCheck> checkPackageTangles = project.getTasks()
.register("checkArchitecture" + StringUtils.capitalize(sourceSet.getName()), ArchitectureCheck.class,
(task) -> {
task.getSourceSet().set(sourceSet.getName());
task.getCompileClasspath().from(sourceSet.getCompileClasspath());
task.setClasses(sourceSet.getOutput().getClassesDirs());
task.getResourcesDirectory().set(sourceSet.getOutput().getResourcesDir());
task.dependsOn(sourceSet.getProcessResourcesTaskName());
task.setDescription("Checks the architecture of the classes of the " + sourceSet.getName()
+ " source set.");
task.setGroup(LifecycleBasePlugin.VERIFICATION_GROUP);
task.getNullMarkedEnabled().set(extension.getNullMarked().getEnabled());
task.getNullMarkedIgnoredPackages().set(extension.getNullMarked().getIgnoredPackages());
});
project.getPlugins()
.withId("org.jetbrains.kotlin.jvm",
(kotlinPlugin) -> registerArchitectureCheck(sourceSet, "kotlin", project).configure((task) -> {
task.setClasses(project.files(project.getTasks()
.named(sourceSet.getCompileTaskName("kotlin"), KotlinCompileTool.class)
.flatMap((compile) -> compile.getDestinationDirectory())));
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);
}
static List<ArchRule> beanMethods(String annotationName) {
return List.of(allBeanMethodsShouldReturnNonPrivateType(),
allBeanMethodsShouldNotHaveConditionalOnClassAnnotation(annotationName));
}
static List<ArchRule> configurationProperties(String annotationName) {
return List.of(allDeprecatedConfigurationPropertiesShouldIncludeSince(annotationName));
}
private static ArchRule allBeanMethodsShouldReturnNonPrivateType() {
static ArchRule allBeanMethodsShouldReturnNonPrivateType() {
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"
.formatted(JavaModifier.PRIVATE),
@ -139,7 +130,7 @@ final class ArchitectureRules {
.allowEmptyShould(true);
}
private static ArchRule allBeanMethodsShouldNotHaveConditionalOnClassAnnotation(String annotationName) {
static ArchRule allBeanMethodsShouldNotHaveConditionalOnClassAnnotation(String annotationName) {
return methodsThatAreAnnotatedWith("org.springframework.context.annotation.Bean").should()
.notBeAnnotatedWith(annotationName)
.because("@ConditionalOnClass on @Bean methods is ineffective - it doesn't prevent "
@ -383,20 +374,6 @@ final class ArchitectureRules {
.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() {
return ArchRuleDefinition.classes()
.that(areRegularAutoConfiguration())
@ -421,8 +398,6 @@ final class ArchitectureRules {
return ArchRuleDefinition.members()
.that()
.areDeclaredInClassesThat(areRegularAutoConfiguration())
.and()
.areDeclaredInClassesThat(areNotKotlinClasses())
.and(areNotDefaultConstructors())
.and(areNotConstants())
.and(dontOverridePublicMethods())
@ -442,18 +417,13 @@ final class ArchitectureRules {
}
static DescribedPredicate<JavaClass> areRegularAutoConfiguration() {
return DescribedPredicate.describe("are regular @AutoConfiguration",
return DescribedPredicate.describe("Regular @AutoConfiguration",
(javaClass) -> javaClass.isAnnotatedWith(AUTOCONFIGURATION_ANNOTATION)
&& !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() {
return DescribedPredicate.describe("are test @AutoConfiguration",
return DescribedPredicate.describe("Test @AutoConfiguration",
(javaClass) -> javaClass.isAnnotatedWith(AUTOCONFIGURATION_ANNOTATION)
&& 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
* 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
*/
class AutoConfigurationChecker {
private final DescribedPredicate<JavaClass> isAutoConfiguration = ArchitectureRules.areRegularAutoConfiguration()
.and(ArchitectureRules.areNotKotlinClasses());
private final DescribedPredicate<JavaClass> isAutoConfiguration = ArchitectureRules.areRegularAutoConfiguration();
EvaluationResult check(JavaClasses javaClasses) {
AutoConfigurations autoConfigurations = new AutoConfigurations();

View File

@ -18,6 +18,15 @@ package org.springframework.boot.build.context.properties;
import java.io.File;
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.RegularFileProperty;
@ -28,8 +37,8 @@ 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;
import tools.jackson.core.StreamReadFeature;
import tools.jackson.databind.json.JsonMapper;
/**
* {@link SourceTask} that checks additional Spring configuration metadata files.
@ -56,16 +65,98 @@ public abstract class CheckAdditionalSpringConfigurationMetadata extends SourceT
@TaskAction
void check() throws IOException {
ConfigurationPropertiesAnalyzer analyzer = new ConfigurationPropertiesAnalyzer(getSource().getFiles());
Report report = new Report(this.projectDir);
analyzer.analyzeSort(report);
analyzer.analyzeDeprecationSince(report);
Report report = createReport();
File reportFile = getReportLocation().get().getAsFile();
report.write(reportFile);
Files.write(reportFile.toPath(), report, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
if (report.hasProblems()) {
throw new VerificationException(
"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.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.Map;
import org.gradle.api.DefaultTask;
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.TaskAction;
import org.gradle.api.tasks.VerificationException;
import org.springframework.boot.build.context.properties.ConfigurationPropertiesAnalyzer.Report;
import tools.jackson.databind.json.JsonMapper;
/**
* {@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 {
private final File projectRoot;
private final Path projectRoot;
public CheckSpringConfigurationMetadata() {
this.projectRoot = getProject().getProjectDir();
this.projectRoot = getProject().getProjectDir().toPath();
}
@OutputFile
@ -59,16 +64,86 @@ public abstract class CheckSpringConfigurationMetadata extends DefaultTask {
@TaskAction
void check() throws IOException {
Report report = new Report(this.projectRoot);
ConfigurationPropertiesAnalyzer analyzer = new ConfigurationPropertiesAnalyzer(
List.of(getMetadataLocation().get().getAsFile()));
analyzer.analyzePropertyDescription(report, getExclusions().get());
Report report = createReport();
File reportFile = getReportLocation().get().getAsFile();
report.write(reportFile);
Files.write(reportFile.toPath(), report, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
if (report.hasProblems()) {
throw new VerificationException(
"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) {
config.accept("spring.jackson");
config.accept("spring.gson");
config.accept("spring.kotlinx.serialization.json");
config.accept("spring.kotlin.serialization");
}
private void dataPrefixes(Config config) {

View File

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

View File

@ -19,11 +19,9 @@ package org.springframework.boot.build.architecture;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
@ -44,7 +42,6 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
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.CollectionUtils;
import org.springframework.util.FileSystemUtils;
@ -343,16 +340,6 @@ class ArchitectureCheckTests {
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 {
for (String sourceDirectory : sourceDirectories) {
FileSystemUtils.copyRecursively(
@ -385,13 +372,8 @@ class ArchitectureCheckTests {
try {
BuildResult buildResult = gradleBuild.buildAndFail(task.toString());
assertThat(buildResult.taskPaths(TaskOutcome.FAILED)).as(buildResult.getOutput()).contains(":" + task);
try {
assertThat(task.getFailureReport(gradleBuild.getProjectDir())).contains(messages);
}
catch (NoSuchFileException ex) {
throw new AssertionError("Expected failure report not found\n" + buildResult.getOutput());
}
}
catch (UnexpectedBuildSuccess ex) {
throw new AssertionError("Expected build to fail but it succeeded\n" + ex.getBuildResult().getOutput(), ex);
}
@ -423,7 +405,7 @@ class ArchitectureCheckTests {
@Override
public String toString() {
return "checkArchitecture" + StringUtils.capitalize(this.sourceSetName) + "Java";
return "checkArchitecture" + StringUtils.capitalize(this.sourceSetName);
}
}
@ -454,18 +436,9 @@ class ArchitectureCheckTests {
return this;
}
GradleBuild withConditionalOnClassAnnotation(String annotationClass) {
GradleBuild withConditionalOnClassAnnotation(String annotationName) {
for (Task task : Task.values()) {
configureTask(task, (configuration) -> configuration
.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));
configureTask(task, (configuration) -> configuration.withConditionalOnClassAnnotation(annotationName));
}
return this;
}
@ -525,18 +498,18 @@ class ArchitectureCheckTests {
for (String dependency : this.dependencies) {
buildFile.append("\n implementation ").append(StringUtils.quote(dependency));
}
buildFile.append("\n}\n\n");
buildFile.append("}\n");
}
this.taskConfigurations.forEach((task, configuration) -> {
buildFile.append(task).append(" {");
if (configuration.conditionalOnClassAnnotation() != null) {
buildFile.append("\n conditionalOnClassAnnotation = ")
.append(StringUtils.quote(configuration.conditionalOnClassAnnotation()));
}
if (configuration.prohibitObjectsRequireNonNull() != null) {
buildFile.append("\n prohibitObjectsRequireNonNull = ")
.append(configuration.prohibitObjectsRequireNonNull());
}
if (configuration.annotations() != null && !configuration.annotations().isEmpty()) {
buildFile.append("\n annotationClasses = ")
.append(toGroovyMapString(configuration.annotations()));
}
buildFile.append("\n}\n");
});
NullMarkedExtension nullMarkedExtension = this.nullMarkedExtension;
@ -563,13 +536,6 @@ class ArchitectureCheckTests {
.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 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 {
if (annotations == null) {
annotations = new HashMap<>();
}
private TaskConfiguration withConditionalOnClassAnnotation(String annotationName) {
return new TaskConfiguration(this.prohibitObjectsRequireNonNull, annotationName);
}
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
void mavenRepositoriesWhenConditionMatches() {
SpringRepositoriesExtension extension = createExtension("0.0.0-SNAPSHOT", "oss");
extension.mavenRepositoriesFor("1.2.3-SNAPSHOT");
extension.mavenRepositories(true);
assertThat(this.repositories).hasSize(1);
}
@Test
void mavenRepositoriesWhenConditionDoesNotMatch() {
SpringRepositoriesExtension extension = createExtension("0.0.0-SNAPSHOT", "oss");
extension.mavenRepositoriesFor("1.2.3");
extension.mavenRepositories(false);
assertThat(this.repositories).isEmpty();
}
@ -252,7 +252,7 @@ class SpringRepositoriesExtensionTests {
void mavenRepositories();
void mavenRepositoriesFor(Object version);
void mavenRepositories(boolean condition);
void mavenRepositoriesExcludingBootGroup();

View File

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

View File

@ -106,25 +106,17 @@ public class Builder {
this.log.start(request);
validateBindings(request.getBindings());
PullPolicy pullPolicy = request.getPullPolicy();
ImagePlatform platform = request.getImagePlatform();
boolean specifiedPlatform = request.getImagePlatform() != null;
DockerRegistryAuthentication registryAuthentication = this.dockerConfiguration.builderRegistryAuthentication();
ImageFetcher imageFetcher = new ImageFetcher(registryAuthentication, pullPolicy);
Image builderImage = imageFetcher.fetchImage(ImageType.BUILDER, request.getBuilder(), platform);
ImageFetcher imageFetcher = new ImageFetcher(this.dockerConfiguration.builderRegistryAuthentication(),
pullPolicy, request.getImagePlatform());
Image builderImage = imageFetcher.fetchImage(ImageType.BUILDER, request.getBuilder());
BuilderMetadata builderMetadata = BuilderMetadata.fromImage(builderImage);
request = withRunImageIfNeeded(request, builderMetadata);
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(), platform);
if (specifiedPlatform && runImage.getPrimaryDigest() != null) {
request = request.withRunImage(request.getRunImage().withDigest(runImage.getPrimaryDigest()));
runImage = imageFetcher.fetchImage(ImageType.RUNNER, request.getRunImage(), platform);
}
Image runImage = imageFetcher.fetchImage(ImageType.RUNNER, request.getRunImage());
assertStackIdsMatch(runImage, builderImage);
BuildOwner buildOwner = BuildOwner.fromEnv(builderImage.getConfig().getEnv());
BuildpackLayersMetadata buildpackLayersMetadata = BuildpackLayersMetadata.fromImage(builderImage);
Buildpacks buildpacks = getBuildpacks(request, imageFetcher, platform, builderMetadata,
buildpackLayersMetadata);
Buildpacks buildpacks = getBuildpacks(request, imageFetcher, builderMetadata, buildpackLayersMetadata);
EphemeralBuilder ephemeralBuilder = new EphemeralBuilder(buildOwner, builderImage, request.getName(),
builderMetadata, request.getCreator(), request.getEnv(), buildpacks);
executeLifecycle(request, ephemeralBuilder);
@ -168,9 +160,9 @@ public class Builder {
}
}
private Buildpacks getBuildpacks(BuildRequest request, ImageFetcher imageFetcher, ImagePlatform platform,
BuilderMetadata builderMetadata, BuildpackLayersMetadata buildpackLayersMetadata) {
BuildpackResolverContext resolverContext = new BuilderResolverContext(imageFetcher, platform, builderMetadata,
private Buildpacks getBuildpacks(BuildRequest request, ImageFetcher imageFetcher, BuilderMetadata builderMetadata,
BuildpackLayersMetadata buildpackLayersMetadata) {
BuildpackResolverContext resolverContext = new BuilderResolverContext(imageFetcher, builderMetadata,
buildpackLayersMetadata);
return BuildpackResolvers.resolveAll(resolverContext, request.getBuildpacks());
}
@ -233,74 +225,49 @@ public class Builder {
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.pullPolicy = pullPolicy;
this.defaultPlatform = platform;
}
Image fetchImage(ImageType type, ImageReference reference, @Nullable ImagePlatform platform)
throws IOException {
Image fetchImage(ImageType type, ImageReference reference) throws IOException {
Assert.notNull(type, "'type' must not be null");
Assert.notNull(reference, "'reference' must not be null");
if (this.pullPolicy == PullPolicy.ALWAYS) {
return pullImageAndCheckForPlatformMismatch(type, reference, platform);
return checkPlatformMismatch(pullImage(reference, type), reference);
}
try {
Image image = Builder.this.docker.image().inspect(reference, platform);
return checkPlatformMismatch(image, reference, platform);
return checkPlatformMismatch(Builder.this.docker.image().inspect(reference), reference);
}
catch (DockerEngineException ex) {
if (this.pullPolicy == PullPolicy.IF_NOT_PRESENT && ex.getStatusCode() == 404) {
return pullImageAndCheckForPlatformMismatch(type, reference, platform);
return checkPlatformMismatch(pullImage(reference, type), reference);
}
throw ex;
}
}
private Image pullImageAndCheckForPlatformMismatch(ImageType type, ImageReference reference,
@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 {
private Image pullImage(ImageReference reference, ImageType imageType) throws IOException {
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);
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);
if (this.defaultPlatform == null) {
this.defaultPlatform = ImagePlatform.from(image);
}
return image;
}
private Image checkPlatformMismatch(Image image, ImageReference reference,
@Nullable ImagePlatform requestedPlatform) {
if (requestedPlatform != null) {
ImagePlatform actualPlatform = ImagePlatform.from(image);
if (!actualPlatform.equals(requestedPlatform)) {
throw new PlatformMismatchException(reference, requestedPlatform, actualPlatform, null);
private Image checkPlatformMismatch(Image image, ImageReference imageReference) {
if (this.defaultPlatform != null) {
ImagePlatform imagePlatform = ImagePlatform.from(image);
if (!imagePlatform.equals(this.defaultPlatform)) {
throw new PlatformMismatchException(imageReference, this.defaultPlatform, imagePlatform);
}
}
return image;
@ -311,9 +278,9 @@ public class Builder {
private static final class PlatformMismatchException extends RuntimeException {
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'"
.formatted(requestedPlatform, imageReference, requestedPlatform, actualPlatform), cause);
.formatted(requestedPlatform, imageReference, requestedPlatform, actualPlatform));
}
}
@ -359,16 +326,13 @@ public class Builder {
private final ImageFetcher imageFetcher;
private final ImagePlatform platform;
private final BuilderMetadata builderMetadata;
private final BuildpackLayersMetadata buildpackLayersMetadata;
BuilderResolverContext(ImageFetcher imageFetcher, ImagePlatform platform, BuilderMetadata builderMetadata,
BuilderResolverContext(ImageFetcher imageFetcher, BuilderMetadata builderMetadata,
BuildpackLayersMetadata buildpackLayersMetadata) {
this.imageFetcher = imageFetcher;
this.platform = platform;
this.builderMetadata = builderMetadata;
this.buildpackLayersMetadata = buildpackLayersMetadata;
}
@ -385,13 +349,13 @@ public class Builder {
@Override
public Image fetchImage(ImageReference reference, ImageType imageType) throws IOException {
return this.imageFetcher.fetchImage(imageType, reference, this.platform);
return this.imageFetcher.fetchImage(imageType, reference);
}
@Override
public void exportImageLayers(ImageReference reference, IOBiConsumer<String, TarArchive> exports)
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;
import java.util.Comparator;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -31,13 +30,10 @@ import org.springframework.util.Assert;
* @author Scott Frederick
* @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 Comparator<ApiVersion> COMPARATOR = Comparator.comparing(ApiVersion::getMajor)
.thenComparing(ApiVersion::getMinor);
private final int major;
private final int minor;
@ -142,9 +138,4 @@ public final class ApiVersion implements Comparable<ApiVersion> {
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"));
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";
@ -124,38 +126,18 @@ public class DockerApi {
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) {
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) {
return buildUrl(Feature.BASELINE, path, params);
return buildUrl(API_VERSION, path, params);
}
URI buildUrl(Feature feature, String path, Object @Nullable ... params) {
ApiVersion version = getApiVersion();
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) {
private URI buildUrl(ApiVersion apiVersion, String path, Object @Nullable ... params) {
verifyApiVersion(apiVersion);
try {
URIBuilder builder = new URIBuilder("/v" + version + path);
URIBuilder builder = new URIBuilder("/v" + apiVersion + path);
if (params != null) {
int param = 0;
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() {
ApiVersion apiVersion = this.apiVersion;
if (apiVersion == null) {
@ -236,8 +225,9 @@ public class DockerApi {
UpdateListener<PullImageUpdateEvent> listener, @Nullable String registryAuth) throws IOException {
Assert.notNull(reference, "'reference' must not be null");
Assert.notNull(listener, "'listener' must not be null");
URI createUri = (platform != null) ? buildUrl(Feature.PLATFORM_IMAGE_PULL, "/images/create", "fromImage",
reference, "platform", platform) : buildUrl("/images/create", "fromImage", reference);
URI createUri = (platform != null)
? buildUrl(PLATFORM_API_VERSION, "/images/create", "fromImage", reference, "platform", platform)
: buildUrl("/images/create", "fromImage", reference);
DigestCaptureUpdateListener digestCapture = new DigestCaptureUpdateListener();
listener.onStart();
try {
@ -247,7 +237,7 @@ public class DockerApi {
listener.onUpdate(event);
});
}
return inspect(reference, platform);
return inspect((platform != null) ? PLATFORM_API_VERSION : API_VERSION, reference);
}
finally {
listener.onFinish();
@ -319,24 +309,9 @@ public class DockerApi {
*/
public void exportLayers(ImageReference reference, IOBiConsumer<String, TarArchive> exports)
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(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 (ExportedImageTar exportedImageTar = new ExportedImageTar(reference, response.getContent())) {
exportedImageTar.exportLayers(exports);
@ -364,25 +339,13 @@ public class DockerApi {
* @throws IOException on IO error
*/
public Image inspect(ImageReference reference) throws IOException {
return inspect(reference, null);
return inspect(API_VERSION, reference);
}
/**
* 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)
private Image inspect(ApiVersion apiVersion, ImageReference reference) throws IOException {
Assert.notNull(reference, "'reference' must not be null");
URI inspectUrl = buildPlatformJsonUrl(Feature.PLATFORM_IMAGE_INSPECT, platform,
"/images/" + reference + "/json");
try (Response response = http().get(inspectUrl)) {
URI imageUri = buildUrl(apiVersion, "/images/" + reference + "/json");
try (Response response = http().get(imageUri)) {
return Image.of(response.getContent());
}
}
@ -430,7 +393,7 @@ public class DockerApi {
private ContainerReference createContainer(ContainerConfig config, @Nullable ImagePlatform platform)
throws IOException {
URI createUri = (platform != null)
? buildUrl(Feature.PLATFORM_CONTAINER_CREATE, "/containers/create", "platform", platform)
? buildUrl(PLATFORM_API_VERSION, "/containers/create", "platform", platform)
: buildUrl("/containers/create");
try (Response response = http().post(createUri, "application/json", config::writeTo)) {
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 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.json.SharedJsonMapper;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* A platform specification for a Docker image.
@ -105,20 +102,4 @@ public class ImagePlatform {
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.Collections;
import java.util.List;
import java.util.Objects;
import org.jspecify.annotations.Nullable;
import tools.jackson.databind.JsonNode;
import org.springframework.boot.buildpack.platform.json.MappedObject;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
/**
@ -54,8 +52,6 @@ public class Image extends MappedObject {
private final @Nullable String created;
private final @Nullable Descriptor descriptor;
Image(JsonNode node) {
super(node, MethodHandles.lookup());
this.digests = childrenAt("/RepoDigests", JsonNode::asString);
@ -65,9 +61,6 @@ public class Image extends MappedObject {
this.architecture = valueAt("/Architecture", String.class);
this.variant = valueAt("/Variant", 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) {
@ -133,35 +126,6 @@ public class Image extends MappedObject {
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.
* @param content the JSON content
@ -172,24 +136,4 @@ public class Image extends MappedObject {
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 PLATFORM_CNB = ImageReference
.of("docker.io/cloudfoundry/run@sha256:fb5ecb90a42b2067a859aab23fc1f5e9d9c2589d07ba285608879e7baa415aad");
@Test
@SuppressWarnings("NullAway") // Test null check
void createWhenLogIsNullThrowsException() {
@ -281,8 +278,8 @@ class BuilderTests {
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(BASE_CNB), eq(ImagePlatform.from(builderImage)), any(), isNull()))
.willAnswer(withPulledImage(runImage));
given(docker.image().inspect(eq(DEFAULT_BUILDER), any())).willReturn(builderImage);
given(docker.image().inspect(eq(BASE_CNB), any())).willReturn(runImage);
given(docker.image().inspect(eq(DEFAULT_BUILDER))).willReturn(builderImage);
given(docker.image().inspect(eq(BASE_CNB))).willReturn(runImage);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest().withPullPolicy(PullPolicy.NEVER);
builder.build(request);
@ -294,7 +291,7 @@ class BuilderTests {
assertThat(tag).isNotNull();
then(docker.image()).should().remove(tag, true);
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
@ -333,11 +330,11 @@ class BuilderTests {
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(BASE_CNB), eq(ImagePlatform.from(builderImage)), any(), isNull()))
.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,
null, null))
.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,
null, null))
.willReturn(runImage);
@ -351,7 +348,7 @@ class BuilderTests {
ImageReference tag = archive.getValue().getTag();
assertThat(tag).isNotNull();
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());
}
@ -428,8 +425,6 @@ class BuilderTests {
given(docker.image().pull(eq(DEFAULT_BUILDER), eq(platform), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
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);
BuildRequest request = getTestRequest().withImagePlatform("linux/arm64/v1");
builder.build(request);
@ -438,7 +433,6 @@ class BuilderTests {
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(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());
ImageReference tag = archive.getValue().getTag();
assertThat(tag).isNotNull();

View File

@ -111,17 +111,6 @@ class ApiVersionTests {
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) {
return ApiVersion.parse(v1).supports(ApiVersion.parse(v2));
}

View File

@ -23,8 +23,6 @@ import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
@ -43,7 +41,6 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
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.SystemApi;
import org.springframework.boot.buildpack.platform.docker.DockerApi.VolumeApi;
@ -89,21 +86,22 @@ import static org.mockito.Mockito.times;
@ExtendWith({ MockitoExtension.class, OutputCaptureExtension.class })
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";
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 PLATFORM_CONTAINERS_URL = PLATFORM_API_URL + "/containers";
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
@SuppressWarnings("NullAway.Init")
private HttpTransport http;
@ -170,52 +168,6 @@ class DockerApiTests {
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
class ImageDockerApiTests {
@ -290,14 +242,15 @@ class DockerApiTests {
@Test
void pullWithPlatformPullsImageAndProducesEvents() throws Exception {
ImageReference reference = ImageReference.of("gcr.io/paketo-buildpacks/builder:base");
URI createUri = new URI(
"/v1.49/images/create?fromImage=gcr.io%2Fpaketo-buildpacks%2Fbuilder%3Abase&platform=linux%2Farm64%2Fv1");
URI imageUri = new URI("/v1.49/images/gcr.io/paketo-buildpacks/builder:base/json?platform="
+ ENCODED_LINUX_ARM64_PLATFORM_JSON);
setVersion("1.49");
ImagePlatform platform = ImagePlatform.of("linux/arm64/v1");
URI createUri = new URI(PLATFORM_IMAGES_URL
+ "/create?fromImage=gcr.io%2Fpaketo-buildpacks%2Fbuilder%3Abase&platform=linux%2Farm64%2Fv1");
URI imageUri = new URI(PLATFORM_IMAGES_URL + "/gcr.io/paketo-buildpacks/builder:base/json");
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().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);
InOrder ordered = inOrder(this.pullListener);
ordered.verify(this.pullListener).onStart();
@ -309,7 +262,8 @@ class DockerApiTests {
void pullWithPlatformAndInsufficientApiVersionThrowsException() throws Exception {
ImageReference reference = ImageReference.of("gcr.io/paketo-buildpacks/builder:base");
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))
.withMessageContaining("must be at least 1.41")
.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");
given(http().get(imageUri)).willReturn(responseOf("type/image.json"));
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);
}
@ -501,65 +431,6 @@ class DockerApiTests {
.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
void exportLayersWithSymlinksExportsLayerTars() throws Exception {
ImageReference reference = ImageReference.of("docker.io/paketobuildpacks/builder:base");
@ -693,19 +564,7 @@ class DockerApiTests {
@Test
void createWithPlatformCreatesContainer() throws Exception {
ImageReference imageReference = ImageReference.of("ubuntu:bionic");
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());
createWithPlatform("1.41");
}
@Test
@ -717,7 +576,11 @@ class DockerApiTests {
ImageReference imageReference = ImageReference.of("ubuntu:bionic");
ContainerConfig config = ContainerConfig.of(imageReference, (update) -> update.withCommand("/bin/bash"));
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()))
.willReturn(responseOf("create-container-response.json"));
ContainerReference containerReference = this.api.create(config, platform);
@ -733,7 +596,8 @@ class DockerApiTests {
ImageReference imageReference = ImageReference.of("ubuntu:bionic");
ContainerConfig config = ContainerConfig.of(imageReference, (update) -> update.withCommand("/bin/bash"));
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))
.withMessageContaining("must be at least 1.41")
.withMessageContaining("current API version is 1.24");

View File

@ -64,20 +64,6 @@ class ImagePlatformTests extends AbstractJsonTests {
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 {
return Image.of(getContent("type/image.json"));
}

View File

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

View File

@ -23,7 +23,6 @@ import java.util.Map;
import org.jspecify.annotations.Nullable;
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 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");
}
@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 {
return Image.of(getContent("image.json"));
}

View File

@ -31,7 +31,7 @@ class FileDescriptorTests {
private final int sourceHandle = 123;
private int closedHandle;
private int closedHandle = 0;
@Test
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",
"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": [
"paketo-buildpacks/cnb:latest"
],

View File

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

View File

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

View File

@ -211,9 +211,8 @@ public class AutoConfigurationImportSelector implements DeferredImportSelector,
private void checkExcludedClasses(List<String> configurations, Set<String> exclusions) {
List<String> invalidExcludes = new ArrayList<>(exclusions.size());
ClassLoader classLoader = (this.beanClassLoader != null) ? this.beanClassLoader : getClass().getClassLoader();
for (String exclusion : exclusions) {
if (ClassUtils.isPresent(exclusion, classLoader) && !configurations.contains(exclusion)) {
if (ClassUtils.isPresent(exclusion, getClass().getClassLoader()) && !configurations.contains(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) {
return getMatchingBeans(beanDefinitions, this::isPrimary);
}
private boolean isPrimary(@Nullable BeanDefinition beanDefinition) {
return (beanDefinition != null) && beanDefinition.isPrimary();
return getMatchingBeans(beanDefinitions,
(beanDefinition) -> beanDefinition != null && beanDefinition.isPrimary());
}
private List<String> getNonFallbackBeans(Map<String, @Nullable BeanDefinition> beanDefinitions) {
return getMatchingBeans(beanDefinitions, this::isNotFallback);
}
private boolean isNotFallback(@Nullable BeanDefinition beanDefinition) {
return (beanDefinition == null) || !beanDefinition.isFallback();
return getMatchingBeans(beanDefinitions,
Predicate.not((beanDefinition) -> beanDefinition != null && beanDefinition.isFallback()));
}
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
* arguments.
*/
private boolean alwaysUseMessageFormat;
private boolean alwaysUseMessageFormat = false;
/**
* Whether to use the message code as the default message instead of throwing a
* "NoSuchMessageException". Recommended during development only.
*/
private boolean useCodeAsDefaultMessage;
private boolean useCodeAsDefaultMessage = false;
public List<String> getBasename() {
return this.basename;

View File

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

View File

@ -24,7 +24,6 @@ import java.util.concurrent.TimeUnit;
import org.jspecify.annotations.Nullable;
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.convert.DurationUnit;
import org.springframework.http.CacheControl;
@ -51,9 +50,6 @@ public class WebProperties {
private final Resources resources = new Resources();
@NestedConfigurationProperty
private final ErrorProperties error = new ErrorProperties();
public @Nullable Locale getLocale() {
return this.locale;
}
@ -70,10 +66,6 @@ public class WebProperties {
this.localeResolver = localeResolver;
}
public ErrorProperties getError() {
return this.error;
}
public Resources getResources() {
return this.resources;
}
@ -109,7 +101,7 @@ public class WebProperties {
*/
private boolean addMappings = true;
private boolean customized;
private boolean customized = false;
private final Chain chain = new Chain();
@ -159,7 +151,7 @@ public class WebProperties {
*/
public static class Chain {
boolean customized;
boolean customized = false;
/**
* 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
* extensions.
*/
private boolean compressed;
private boolean compressed = false;
private final Strategy strategy = new Strategy();
@ -254,7 +246,7 @@ public class WebProperties {
*/
public static class Content {
private boolean customized;
private boolean customized = false;
/**
* Whether to enable the content Version Strategy.
@ -295,7 +287,7 @@ public class WebProperties {
*/
public static class Fixed {
private boolean customized;
private boolean customized = false;
/**
* Whether to enable the fixed Version Strategy.
@ -354,7 +346,7 @@ public class WebProperties {
*/
public static class Cache {
private boolean customized;
private boolean customized = false;
/**
* Cache period for the resources served by the resource handler. If a
@ -406,7 +398,7 @@ public class WebProperties {
*/
public static class Cachecontrol {
private boolean customized;
private boolean customized = false;
/**
* 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.",
"deprecation": {
"replacement": "spring.info.git.location",
"level": "error",
"since": "1.4.0"
"level": "error"
}
},
{
@ -59,8 +58,7 @@
"type": "java.lang.Boolean",
"deprecation": {
"replacement": "spring.web.resources.add-mappings",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -68,8 +66,7 @@
"type": "java.lang.Boolean",
"deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.cache-private",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -77,8 +74,7 @@
"type": "java.lang.Boolean",
"deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.cache-public",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -86,8 +82,7 @@
"type": "java.time.Duration",
"deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.max-age",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -95,8 +90,7 @@
"type": "java.lang.Boolean",
"deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.must-revalidate",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -104,8 +98,7 @@
"type": "java.lang.Boolean",
"deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.no-cache",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -113,8 +106,7 @@
"type": "java.lang.Boolean",
"deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.no-store",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -122,8 +114,7 @@
"type": "java.lang.Boolean",
"deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.no-transform",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -131,8 +122,7 @@
"type": "java.lang.Boolean",
"deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.proxy-revalidate",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -140,8 +130,7 @@
"type": "java.time.Duration",
"deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.s-max-age",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -149,8 +138,7 @@
"type": "java.time.Duration",
"deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.stale-if-error",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -158,8 +146,7 @@
"type": "java.time.Duration",
"deprecation": {
"replacement": "spring.web.resources.cache.cachecontrol.stale-while-revalidate",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -167,8 +154,7 @@
"type": "java.time.Duration",
"deprecation": {
"replacement": "spring.web.resources.cache.period",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -176,8 +162,7 @@
"type": "java.lang.Boolean",
"deprecation": {
"replacement": "spring.web.resources.cache.use-last-modified",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -185,8 +170,7 @@
"type": "java.lang.Boolean",
"deprecation": {
"replacement": "spring.web.resources.chain.cache",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -194,8 +178,7 @@
"type": "java.lang.Boolean",
"deprecation": {
"replacement": "spring.web.resources.chain.compressed",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -203,8 +186,7 @@
"type": "java.lang.Boolean",
"deprecation": {
"replacement": "spring.web.resources.chain.enabled",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -212,16 +194,14 @@
"type": "java.lang.Boolean",
"deprecation": {
"replacement": "spring.web.resources.chain.compressed",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
"name": "spring.resources.chain.html-application-cache",
"type": "java.lang.Boolean",
"deprecation": {
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -229,8 +209,7 @@
"type": "java.lang.Boolean",
"deprecation": {
"replacement": "spring.web.resources.chain.strategy.content.enabled",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -238,8 +217,7 @@
"type": "java.lang.String[]",
"deprecation": {
"replacement": "spring.web.resources.chain.strategy.content.paths",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -247,8 +225,7 @@
"type": "java.lang.Boolean",
"deprecation": {
"replacement": "spring.web.resources.chain.strategy.fixed.enabled",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -256,8 +233,7 @@
"type": "java.lang.String[]",
"deprecation": {
"replacement": "spring.web.resources.chain.strategy.fixed.paths",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -265,8 +241,7 @@
"type": "java.lang.String",
"deprecation": {
"replacement": "spring.web.resources.chain.strategy.fixed.version",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{
@ -274,8 +249,7 @@
"type": "java.lang.String[]",
"deprecation": {
"replacement": "spring.web.resources.static-locations",
"level": "error",
"since": "2.4.0"
"level": "error"
}
},
{

View File

@ -1,6 +1,3 @@
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.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)
@ConditionalOnSingleCandidate(String.class)
static class OnBeanSingleCandidateConfiguration {

View File

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

View File

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

View File

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

View File

@ -9,9 +9,8 @@
{
"name": "spring.test.observability.auto-configure",
"deprecation": {
"level": "error",
"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 {
@Value("${value}")
private int value;
private int value = 0;
@Autowired
private WebApplicationContext context;

View File

@ -144,7 +144,7 @@ class ImportTestcontainersTests {
@ImportTestcontainers
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 volatile boolean shutdownHookAdditionEnabled;
private volatile boolean shutdownHookAdditionEnabled = false;
private boolean inProgress;

View File

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

View File

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

View File

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

View File

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

View File

@ -39,7 +39,7 @@ public final class RetryPolicySettings {
/**
* 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.
@ -62,7 +62,7 @@ public final class RetryPolicySettings {
private @Nullable Predicate<Throwable> exceptionPredicate;
private Long maxRetries = DEFAULT_MAX_RETRIES;
private Long maxAttempts = DEFAULT_MAX_ATTEMPTS;
private Duration delay = DEFAULT_DELAY;
@ -84,7 +84,7 @@ public final class RetryPolicySettings {
map.from(this::getExceptionIncludes).to(builder::includes);
map.from(this::getExceptionExcludes).to(builder::excludes);
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::getJitter).to(builder::jitter);
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
* @see #DEFAULT_MAX_RETRIES
* @see #DEFAULT_MAX_ATTEMPTS
*/
public Long getMaxRetries() {
return this.maxRetries;
public Long getMaxAttempts() {
return this.maxAttempts;
}
/**
* Specify the maximum number of retry attempts.
* @param maxRetries the maximum number of retry attempts (must be equal or greater
* than zero)
* @param maxAttempts the max attempts (must be equal or greater than zero)
*/
public void setMaxRetries(Long maxRetries) {
this.maxRetries = maxRetries;
public void setMaxAttempts(Long maxAttempts) {
this.maxAttempts = maxAttempts;
}
/**

View File

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

View File

@ -40,7 +40,6 @@ import org.springframework.util.StringUtils;
* {@link SystemEnvironmentOrigin} for every system environment property.
*
* @author Madhura Bhave
* @author Phillip Webb
* @since 4.0.0
*/
public class SystemEnvironmentPropertySourceEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {
@ -54,14 +53,10 @@ public class SystemEnvironmentPropertySourceEnvironmentPostProcessor implements
@Override
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;
PropertySource<?> propertySource = environment.getPropertySources().get(sourceName);
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;
}
/**
* 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}.
*/

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

View File

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

View File

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

View File

@ -111,19 +111,4 @@ class SystemEnvironmentPropertySourceEnvironmentPostProcessorTests {
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-registry-graphite")
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.projectreactor.netty:reactor-netty-http")
implementation("jakarta.annotation:jakarta.annotation-api")
@ -370,12 +367,6 @@ def configurationPropertiesMetadataAggregate = aggregates.create("configurationP
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) {
configurationPropertyMetadata = configurationPropertiesMetadataAggregate.files
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[#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.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[#deployment.cloud.aws]
* 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]]
=== 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].
See also the related https://www.liquibase.com/blog/contexts-vs-labels[blog post].
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.
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
--changeset alice:1 context:@test
insert into project (id, name) values (1, 'Spring Boot');
spring:
liquibase:
change-log: "classpath:/db/changelog/db.changelog-test.yaml"
----
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]]
== 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.
Popular options are:
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.
The options include:
* Amazon Elastic Container Service (ECS)
* AWS Elastic Beanstalk
* AWS Code Deploy
* AWS OPS Works
* AWS Cloud Formation
* AWS Container Registry
[[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.
Each has different features and pricing models.
In this document, we describe to approach using AWS Elastic Beanstalk.
@ -286,9 +278,8 @@ You can either use the "`Tomcat Platform`" or the "`Java SE platform`".
==== Using the Tomcat Platform
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].
NOTE: Creating deployable war for a Spring Boot application is described in xref:deployment/traditional-deployment.adoc[].
No special configuration is required.
You need only follow the official guide.
@ -296,8 +287,51 @@ NOTE: Creating deployable war for a Spring Boot application is described in xref
==== Using the Java SE Platform
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].
There are also community provided tutorials such as https://www.baeldung.com/spring-boot-deploy-aws-beanstalk[this one].
Elastic Beanstalk environments run an nginx instance on port 80 to proxy the actual application, running on port 5000.
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.
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`.
For example:
If you use Maven, the following example marks the servlet runtime (Tomcat, in this case) as being provided:
[source,xml]
----
@ -43,21 +43,20 @@ For example:
<!-- ... -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<artifactId>spring-boot-tomcat-runtime</artifactId>
<scope>provided</scope>
</dependency>
<!-- ... -->
</dependencies>
----
If you use Gradle, you need to move only the runtime dependencies into the `providedRuntime` configuration.
For example:
If you use Gradle, the following example marks the servlet runtime (Tomcat, in this case) as being provided:
[source,gradle]
----
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.
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]]

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).
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.
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.
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]
----
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<!-- Exclude the Tomcat dependency -->
<exclusion>
@ -44,70 +39,23 @@ Maven::
<artifactId>spring-boot-starter-jetty</artifactId>
</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]
----
dependencies {
implementation('org.springframework.boot:spring-boot-starter-webmvc') {
// Exclude the Tomcat dependency
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
implementation "org.springframework.boot:spring-boot-starter-tomcat"
implementation "org.springframework.boot:spring-boot-starter-webflux"
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:
[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"
}
----
+
======
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.

View File

@ -383,17 +383,6 @@ management:
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]]
== 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: 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 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`.
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.
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:
@ -1271,7 +1260,7 @@ The `info` endpoint publishes information about your process, see javadoc:org.sp
[[actuator.endpoints.info.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.
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].
@ -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]]
== 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.
NOTE: Spring Boot does not provide automatic exporting of OpenTelemetry metrics or logs.
Exporting OpenTelemetry traces 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: Spring Boot does not provide auto-configuration for OpenTelemetry metrics or logging.
OpenTelemetry tracing is only auto-configured when used together with xref:actuator/tracing.adoc[Micrometer Tracing].
NOTE: The `OTEL_RESOURCE_ATTRIBUTES` environment variable consists of a list of key-value pairs.
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**.
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`]
* 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[]
The next sections will provide more details about logging, metrics and traces.
[[actuator.observability.opentelemetry.logging]]
=== Logging
[[actuator.observability.annotations]]
== Micrometer Observation Annotations support
The javadoc:org.springframework.boot.opentelemetry.autoconfigure.logging.OpenTelemetryLoggingAutoConfiguration[] configures OpenTelemetry's javadoc:io.opentelemetry.sdk.logs.SdkLoggerProvider[].
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.
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.
However, while there is a `SdkLoggerProvider` bean, Spring Boot doesn't support bridging logs to this bean out of the box.
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.
[[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.
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.

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:
[configprops,yaml]
@ -350,17 +350,18 @@ spring:
elasticsearch:
restclient:
sniffer:
enabled: true
interval: "10m"
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]]
==== 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[].
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.
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).
* Pre-initialized nested POJOs (like the `Security` 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.
* 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.
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.
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.
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]]

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.
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).
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:
* 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.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]]
==== 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.
* 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.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
@DeprecatedConfigurationProperty(replacement = "my.app.name", since = "1.2.0")
@DeprecatedConfigurationProperty(replacement = "my.app.name")
public String getTarget() {
return this.name;
}

View File

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

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