Restructure project directories to better fit Gradle

Closes gh-46358
This commit is contained in:
Phillip Webb
2025-07-10 17:54:08 -07:00
parent eec3e9b436
commit decc32dde3
10576 changed files with 3501 additions and 3403 deletions
@@ -0,0 +1,58 @@
/*
* 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.
*/
plugins {
id "war"
id "org.springframework.boot.system-test"
}
description = "Spring Boot Deployment Tests"
configurations {
providedRuntime {
extendsFrom dependencyManagement
}
}
configurations.all {
exclude module: "spring-boot-starter-logging"
}
dependencies {
compileOnly("jakarta.servlet:jakarta.servlet-api")
implementation(project(":starter:spring-boot-starter-web")) {
exclude group: "org.hibernate.validator"
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
}
implementation(project(":starter:spring-boot-starter-actuator"))
systemTestImplementation(enforcedPlatform(project(path: ":platform:spring-boot-internal-dependencies")))
systemTestImplementation(project(":starter:spring-boot-starter-test"))
systemTestImplementation(project(":test-support:spring-boot-test-support"))
systemTestImplementation("org.apache.httpcomponents.client5:httpclient5")
systemTestImplementation("org.testcontainers:junit-jupiter")
systemTestImplementation("org.testcontainers:testcontainers")
systemTestImplementation("org.springframework:spring-web")
}
systemTest {
inputs.files(war).withNormalizer(ClasspathNormalizer).withPropertyName("war")
}
war {
archiveVersion = ''
}
@@ -0,0 +1,25 @@
/*
* 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 sample.app;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
@SpringBootApplication
public class DeploymentTestApplication extends SpringBootServletInitializer {
}
@@ -0,0 +1,30 @@
/*
* 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 sample.app;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class SampleController {
@GetMapping("/")
public String hello() {
return "Hello World";
}
}
@@ -0,0 +1,44 @@
/*
* 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 sample.autoconfig;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWarDeployment;
import org.springframework.context.annotation.Bean;
@ConditionalOnWarDeployment
@AutoConfiguration
public class ExampleAutoConfiguration {
@Bean
public TestEndpoint testEndpoint() {
return new TestEndpoint();
}
@Endpoint(id = "war")
static class TestEndpoint {
@ReadOperation
String hello() {
return "{\"hello\":\"world\"}";
}
}
}
@@ -0,0 +1 @@
management.endpoints.web.exposure.include: '*'
@@ -0,0 +1,150 @@
/*
* 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.deployment;
import java.io.File;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import org.apache.hc.client5.http.impl.DefaultHttpRequestRetryStrategy;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.util.TimeValue;
import org.awaitility.Awaitility;
import org.awaitility.core.ConditionTimeoutException;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.images.builder.ImageFromDockerfile;
import org.testcontainers.images.builder.dockerfile.DockerfileBuilder;
import org.springframework.boot.restclient.RestTemplateBuilder;
import org.springframework.boot.web.server.test.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Abstract class for deployment tests.
*/
abstract class AbstractDeploymentTests {
protected static final int DEFAULT_PORT = 8080;
@Test
void home() {
getDeployedApplication().test((rest) -> {
ResponseEntity<String> response = rest.getForEntity("/", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isEqualTo("Hello World");
});
}
@Test
void health() {
getDeployedApplication().test((rest) -> {
ResponseEntity<String> response = rest.getForEntity("/actuator/health", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isEqualTo("{\"status\":\"UP\"}");
});
}
@Test
void conditionalOnWarShouldBeTrue() {
getDeployedApplication().test((rest) -> {
ResponseEntity<String> response = rest.getForEntity("/actuator/war", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isEqualTo("{\"hello\":\"world\"}");
});
}
private DeployedApplication getDeployedApplication() {
return new DeployedApplication(getContainer(), getPort());
}
protected int getPort() {
return DEFAULT_PORT;
}
abstract WarDeploymentContainer getContainer();
static final class DeployedApplication {
private final WarDeploymentContainer container;
private final int port;
DeployedApplication(WarDeploymentContainer container, int port) {
this.container = container;
this.port = port;
}
private void test(Consumer<TestRestTemplate> consumer) {
TestRestTemplate rest = new TestRestTemplate(new RestTemplateBuilder()
.rootUri("http://" + this.container.getHost() + ":" + this.container.getMappedPort(this.port)
+ "/spring-boot")
.requestFactory(() -> new HttpComponentsClientHttpRequestFactory(HttpClients.custom()
.setRetryStrategy(new DefaultHttpRequestRetryStrategy(10, TimeValue.of(1, TimeUnit.SECONDS)))
.build())));
try {
Awaitility.await().atMost(Duration.ofMinutes(10)).until(() -> {
try {
System.out.println(this.container.getLogs());
consumer.accept(rest);
return true;
}
catch (Throwable ex) {
return false;
}
});
}
catch (ConditionTimeoutException ex) {
System.out.println(this.container.getLogs());
throw ex;
}
}
}
static final class WarDeploymentContainer extends GenericContainer<WarDeploymentContainer> {
WarDeploymentContainer(String baseImage, String deploymentLocation, int port) {
this(baseImage, deploymentLocation, port, null);
}
WarDeploymentContainer(String baseImage, String deploymentLocation, int port,
Consumer<DockerfileBuilder> dockerfileCustomizer) {
super(new ImageFromDockerfile().withFileFromFile("spring-boot.war", findWarToDeploy())
.withDockerfileFromBuilder((builder) -> {
builder.from(baseImage).add("spring-boot.war", deploymentLocation + "/spring-boot.war");
if (dockerfileCustomizer != null) {
dockerfileCustomizer.accept(builder);
}
}));
withExposedPorts(port).withStartupTimeout(Duration.ofMinutes(5)).withStartupAttempts(3);
}
private static File findWarToDeploy() {
File[] candidates = new File("build/libs").listFiles();
assertThat(candidates).hasSize(1);
return candidates[0];
}
}
}
@@ -0,0 +1,48 @@
/*
* 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.deployment;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
/**
* Deployment tests for Open Liberty.
*
* @author Christoph Dreis
* @author Scott Frederick
*/
@Testcontainers(disabledWithoutDocker = true)
class OpenLibertyDeploymentTests extends AbstractDeploymentTests {
private static final int PORT = 9080;
@Container
static WarDeploymentContainer container = new WarDeploymentContainer(
"icr.io/appcafe/open-liberty:full-java17-openj9-ubi", "/config/dropins", PORT,
(builder) -> builder.run("sed -i 's/javaee-8.0/jakartaee-10.0/g' /config/server.xml"));
@Override
WarDeploymentContainer getContainer() {
return container;
}
@Override
protected int getPort() {
return PORT;
}
}
@@ -0,0 +1,40 @@
/*
* 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.deployment;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
/**
* Deployment tests for TomEE.
*
* @author Christoph Dreis
* @author Scott Frederick
*/
@Testcontainers(disabledWithoutDocker = true)
class TomEEDeploymentTests extends AbstractDeploymentTests {
@Container
static WarDeploymentContainer container = new WarDeploymentContainer("tomee:9.1.1-jre17-webprofile",
"/usr/local/tomee/webapps", DEFAULT_PORT);
@Override
WarDeploymentContainer getContainer() {
return container;
}
}
@@ -0,0 +1,40 @@
/*
* 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.deployment;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
/**
* Deployment tests for Tomcat.
*
* @author Christoph Dreis
* @author Scott Frederick
*/
@Testcontainers(disabledWithoutDocker = true)
class TomcatDeploymentTests extends AbstractDeploymentTests {
@Container
static WarDeploymentContainer container = new WarDeploymentContainer("tomcat:10.1.15-jdk17",
"/usr/local/tomcat/webapps", DEFAULT_PORT);
@Override
WarDeploymentContainer getContainer() {
return container;
}
}
@@ -0,0 +1,40 @@
/*
* 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.deployment;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
/**
* Deployment tests for Wildfly.
*
* @author Christoph Dreis
* @author Scott Frederick
*/
@Testcontainers(disabledWithoutDocker = true)
class WildflyDeploymentTests extends AbstractDeploymentTests {
@Container
static WarDeploymentContainer container = new WarDeploymentContainer("quay.io/wildfly/wildfly:27.0.0.Final-jdk17",
"/opt/jboss/wildfly/standalone/deployments", DEFAULT_PORT);
@Override
WarDeploymentContainer getContainer() {
return container;
}
}
@@ -0,0 +1,64 @@
/*
* 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.
*/
plugins {
id 'java-gradle-plugin'
id "org.springframework.boot.system-test"
}
description = "Spring Boot Image Building Tests"
configurations {
app
providedRuntime {
extendsFrom dependencyManagement
}
}
tasks.register("syncMavenRepository", Sync) {
from configurations.app
into layout.buildDirectory.dir("system-test-maven-repository")
}
systemTest {
dependsOn syncMavenRepository
if (project.hasProperty("springBootVersion")) {
systemProperty "springBootVersion", project.properties["springBootVersion"]
} else {
systemProperty "springBootVersion", project.getVersion()
}
}
dependencies {
app project(path: ":build-plugin:spring-boot-gradle-plugin", configuration: "mavenRepository")
app project(path: ":starter:spring-boot-starter-web", configuration: "mavenRepository")
implementation(project(":starter:spring-boot-starter-web")) {
exclude group: "org.hibernate.validator"
}
systemTestImplementation(project(":starter:spring-boot-starter-test"))
systemTestImplementation(project(":test-support:spring-boot-gradle-test-support"))
systemTestImplementation(project(":buildpack:spring-boot-buildpack-platform"))
systemTestImplementation(gradleTestKit())
systemTestImplementation("org.assertj:assertj-core")
systemTestImplementation("org.testcontainers:junit-jupiter")
systemTestImplementation("org.testcontainers:testcontainers")
}
toolchain {
maximumCompatibleJavaVersion = JavaLanguageVersion.of(23)
}
@@ -0,0 +1,149 @@
/*
* 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.image.assertions;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
import com.github.dockerjava.api.model.ContainerConfig;
import org.assertj.core.api.AbstractAssert;
import org.assertj.core.api.AbstractListAssert;
import org.assertj.core.api.AbstractMapAssert;
import org.assertj.core.api.AbstractObjectAssert;
import org.assertj.core.api.ListAssert;
import org.assertj.core.api.ObjectAssert;
import org.springframework.boot.test.json.JsonContentAssert;
/**
* AssertJ {@link org.assertj.core.api.Assert} for Docker image container configuration.
*
* @author Scott Frederick
*/
public class ContainerConfigAssert extends AbstractAssert<ContainerConfigAssert, ContainerConfig> {
private static final String BUILD_METADATA_LABEL = "io.buildpacks.build.metadata";
private static final String LIFECYCLE_METADATA_LABEL = "io.buildpacks.lifecycle.metadata";
ContainerConfigAssert(ContainerConfig containerConfig) {
super(containerConfig, ContainerConfigAssert.class);
}
public void buildMetadata(Consumer<BuildMetadataAssert> assertConsumer) {
assertConsumer.accept(new BuildMetadataAssert(jsonLabel(BUILD_METADATA_LABEL)));
}
public void lifecycleMetadata(Consumer<LifecycleMetadataAssert> assertConsumer) {
assertConsumer.accept(new LifecycleMetadataAssert(jsonLabel(LIFECYCLE_METADATA_LABEL)));
}
public void labels(Consumer<LabelsAssert> assertConsumer) {
assertConsumer.accept(new LabelsAssert(this.actual.getLabels()));
}
private JsonContentAssert jsonLabel(String label) {
return new JsonContentAssert(ContainerConfigAssert.class, getLabel(label));
}
private String getLabel(String label) {
Map<String, String> labels = this.actual.getLabels();
if (labels == null) {
failWithMessage("Container config contains no labels");
}
if (!labels.containsKey(label)) {
failWithActualExpectedAndMessage(labels, label, "Expected label not found in container config");
}
return labels.get(label);
}
/**
* Asserts for labels on an image.
*/
public static class LabelsAssert extends AbstractMapAssert<LabelsAssert, Map<String, String>, String, String> {
protected LabelsAssert(Map<String, String> labels) {
super(labels, LabelsAssert.class);
}
}
/**
* Asserts for the JSON content in the {@code io.buildpacks.build.metadata} label.
*
* See <a href=
* "https://github.com/buildpacks/spec/blob/main/platform.md#iobuildpacksbuildmetadata-json">the
* spec</a>
*/
public static class BuildMetadataAssert extends AbstractAssert<BuildMetadataAssert, JsonContentAssert> {
BuildMetadataAssert(JsonContentAssert jsonContentAssert) {
super(jsonContentAssert, BuildMetadataAssert.class);
}
public ListAssert<Object> buildpacks() {
return this.actual.extractingJsonPathArrayValue("$.buildpacks[*].id");
}
public AbstractListAssert<?, List<? extends String>, String, ObjectAssert<String>> processOfType(String type) {
return this.actual.extractingJsonPathArrayValue("$.processes[?(@.type=='%s')]", type)
.singleElement()
.extracting("command", "args")
.flatMap(this::getArgs);
}
private Collection<String> getArgs(Object obj) {
if (obj instanceof List<?> list) {
return list.stream().map(Objects::toString).toList();
}
return Collections.emptyList();
}
}
/**
* Asserts for the JSON content in the {@code io.buildpacks.lifecycle.metadata} label.
*
* See <a href=
* "https://github.com/buildpacks/spec/blob/main/platform.md#iobuildpackslifecyclemetadata-json">the
* spec</a>
*/
public static class LifecycleMetadataAssert extends AbstractAssert<LifecycleMetadataAssert, JsonContentAssert> {
LifecycleMetadataAssert(JsonContentAssert jsonContentAssert) {
super(jsonContentAssert, LifecycleMetadataAssert.class);
}
public ListAssert<Object> buildpackLayers(String buildpackId) {
return this.actual.extractingJsonPathArrayValue("$.buildpacks[?(@.key=='%s')].layers", buildpackId);
}
public AbstractListAssert<?, List<?>, Object, ObjectAssert<Object>> appLayerShas() {
return this.actual.extractingJsonPathArrayValue("$.app").extracting("sha");
}
public AbstractObjectAssert<?, Object> sbomLayerSha() {
return this.actual.extractingJsonPathValue("$.sbom.sha");
}
}
}
@@ -0,0 +1,124 @@
/*
* 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.image.assertions;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.function.Consumer;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.assertj.core.api.AbstractAssert;
import org.assertj.core.api.Assertions;
import org.assertj.core.api.ListAssert;
import org.springframework.boot.buildpack.platform.docker.DockerApi;
import org.springframework.boot.buildpack.platform.docker.type.ImageReference;
import org.springframework.boot.buildpack.platform.docker.type.Layer;
import org.springframework.boot.test.json.JsonContentAssert;
import org.springframework.util.StreamUtils;
/**
* AssertJ {@link org.assertj.core.api.Assert} for Docker image contents.
*
* @author Scott Frederick
*/
public class ImageAssert extends AbstractAssert<ImageAssert, ImageReference> {
private final HashMap<String, Layer> layers = new HashMap<>();
ImageAssert(ImageReference imageReference) throws IOException {
super(imageReference, ImageAssert.class);
getLayers();
}
public void layer(String layerDigest, Consumer<LayerContentAssert> assertConsumer) {
if (!this.layers.containsKey(layerDigest)) {
failWithMessage("Layer with digest '%s' not found in image", layerDigest);
}
assertConsumer.accept(new LayerContentAssert(this.layers.get(layerDigest)));
}
private void getLayers() throws IOException {
new DockerApi().image().exportLayers(this.actual, (id, tarArchive) -> {
Layer layer = Layer.fromTarArchive(tarArchive);
this.layers.put(layer.getId().toString(), layer);
});
}
/**
* Asserts for image layers.
*/
public static class LayerContentAssert extends AbstractAssert<LayerContentAssert, Layer> {
public LayerContentAssert(Layer layer) {
super(layer, LayerContentAssert.class);
}
public ListAssert<String> entries() {
List<String> entryNames = new ArrayList<>();
try {
ByteArrayOutputStream out = new ByteArrayOutputStream();
this.actual.writeTo(out);
try (TarArchiveInputStream in = new TarArchiveInputStream(
new ByteArrayInputStream(out.toByteArray()))) {
TarArchiveEntry entry = in.getNextEntry();
while (entry != null) {
if (!entry.isDirectory()) {
entryNames.add(entry.getName().replaceFirst("^/workspace/", ""));
}
entry = in.getNextEntry();
}
}
}
catch (IOException ex) {
failWithMessage("IOException while reading image layer archive: '%s'", ex.getMessage());
}
return Assertions.assertThat(entryNames);
}
public void jsonEntry(String name, Consumer<JsonContentAssert> assertConsumer) {
try {
ByteArrayOutputStream out = new ByteArrayOutputStream();
this.actual.writeTo(out);
try (TarArchiveInputStream in = new TarArchiveInputStream(
new ByteArrayInputStream(out.toByteArray()))) {
TarArchiveEntry entry = in.getNextEntry();
while (entry != null) {
if (entry.getName().equals(name)) {
ByteArrayOutputStream entryOut = new ByteArrayOutputStream();
StreamUtils.copy(in, entryOut);
assertConsumer.accept(new JsonContentAssert(LayerContentAssert.class, entryOut.toString()));
return;
}
entry = in.getNextEntry();
}
}
failWithMessage("Expected JSON entry '%s' in layer with digest '%s'", name, this.actual.getId());
}
catch (IOException ex) {
failWithMessage("IOException while reading image layer archive: '%s'", ex.getMessage());
}
}
}
}
@@ -0,0 +1,44 @@
/*
* 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.image.assertions;
import java.io.IOException;
import com.github.dockerjava.api.model.ContainerConfig;
import org.springframework.boot.buildpack.platform.docker.type.ImageReference;
/**
* Factory class for custom AssertJ {@link org.assertj.core.api.Assert}s related to images
* and containers.
*
* @author Scott Frederick
*/
public final class ImageAssertions {
private ImageAssertions() {
}
public static ContainerConfigAssert assertThat(ContainerConfig containerConfig) {
return new ContainerConfigAssert(containerConfig);
}
public static ImageAssert assertThat(ImageReference imageReference) throws IOException {
return new ImageAssert(imageReference);
}
}
@@ -0,0 +1,54 @@
/*
* 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.image.junit;
import java.lang.reflect.Field;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.springframework.boot.testsupport.gradle.testkit.GradleBuild;
import org.springframework.boot.testsupport.gradle.testkit.GradleVersions;
import org.springframework.util.Assert;
import org.springframework.util.ReflectionUtils;
/**
* A {@link BeforeEachCallback} to configure and set a test class's {@code gradleBuild}
* field prior to test execution.
*
* @author Scott Frederick
*/
public class GradleBuildInjectionExtension implements BeforeEachCallback {
private final GradleBuild gradleBuild;
GradleBuildInjectionExtension() {
this.gradleBuild = new GradleBuild();
this.gradleBuild.gradleVersion(GradleVersions.minimumCompatible());
String bootVersion = System.getProperty("springBootVersion");
Assert.state(bootVersion != null, "Property 'springBootVersion' must be set in build environment");
this.gradleBuild.bootVersion(bootVersion);
}
@Override
public void beforeEach(ExtensionContext context) throws Exception {
Field field = ReflectionUtils.findField(context.getRequiredTestClass(), "gradleBuild");
field.setAccessible(true);
field.set(context.getRequiredTestInstance(), this.gradleBuild);
}
}
@@ -0,0 +1,64 @@
/*
* 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.image.paketo;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.jar.JarFile;
import java.util.zip.ZipEntry;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.Constructor;
/**
* Index file describing the layers in the jar or war file and the files or directories in
* each layer.
*
* @author Scott Frederick
*/
class LayersIndex extends ArrayList<Map<String, List<String>>> {
List<String> getLayer(String layerName) {
return stream().filter((entry) -> entry.containsKey(layerName))
.findFirst()
.map((entry) -> entry.get(layerName))
.orElse(Collections.emptyList());
}
static LayersIndex fromArchiveFile(File archiveFile) throws IOException {
String indexPath = (archiveFile.getName().endsWith(".war") ? "WEB-INF/layers.idx" : "BOOT-INF/layers.idx");
try (JarFile jarFile = new JarFile(archiveFile)) {
ZipEntry indexEntry = jarFile.getEntry(indexPath);
Yaml yaml = new Yaml(new Constructor(LayersIndex.class, getLoaderOptions()));
return yaml.load(jarFile.getInputStream(indexEntry));
}
}
private static LoaderOptions getLoaderOptions() {
LoaderOptions loaderOptions = new LoaderOptions();
loaderOptions.setAllowDuplicateKeys(false);
loaderOptions.setMaxAliasesForCollections(Integer.MAX_VALUE);
loaderOptions.setAllowRecursiveKeys(true);
return loaderOptions;
}
}
@@ -0,0 +1,613 @@
/*
* 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.image.paketo;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.jar.Attributes;
import java.util.jar.JarFile;
import com.github.dockerjava.api.model.ContainerConfig;
import org.assertj.core.api.Condition;
import org.gradle.testkit.runner.BuildResult;
import org.gradle.testkit.runner.TaskOutcome;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.springframework.boot.buildpack.platform.docker.DockerApi;
import org.springframework.boot.buildpack.platform.docker.type.ImageName;
import org.springframework.boot.buildpack.platform.docker.type.ImageReference;
import org.springframework.boot.image.assertions.ImageAssertions;
import org.springframework.boot.image.junit.GradleBuildInjectionExtension;
import org.springframework.boot.testsupport.gradle.testkit.GradleBuild;
import org.springframework.boot.testsupport.gradle.testkit.GradleBuildExtension;
import org.springframework.boot.testsupport.gradle.testkit.GradleVersions;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.entry;
/**
* Integration tests for the Paketo builder and buildpacks.
*
* See
* https://paketo.io/docs/buildpacks/language-family-buildpacks/java/#additional-metadata
*
* @author Scott Frederick
*/
@ExtendWith({ GradleBuildInjectionExtension.class, GradleBuildExtension.class })
class PaketoBuilderTests {
GradleBuild gradleBuild;
@BeforeEach
void configureGradleBuild() {
this.gradleBuild.scriptProperty("systemTestMavenRepository",
new File("build/system-test-maven-repository").getAbsoluteFile().toURI().toASCIIString());
this.gradleBuild.scriptPropertyFrom(new File("../../gradle.properties"), "nativeBuildToolsVersion");
this.gradleBuild.expectDeprecationMessages("BPL_SPRING_CLOUD_BINDINGS_ENABLED.*true.*Deprecated");
this.gradleBuild.expectDeprecationMessages("Command \"packages\" is deprecated, use `syft scan` instead");
this.gradleBuild.expectDeprecationMessages("BP_ENABLE_RUNTIME_CERT_BINDING.*true.*Deprecated");
this.gradleBuild.gradleVersion(GradleVersions.maximumCompatible());
}
@Test
void executableJarApp() throws Exception {
writeMainClass();
String imageName = "paketo-integration/" + this.gradleBuild.getProjectDir().getName();
ImageReference imageReference = ImageReference.of(ImageName.of(imageName));
BuildResult result = buildImage(imageName);
assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("Running creator");
try (GenericContainer<?> container = new GenericContainer<>(imageName)) {
container.withExposedPorts(8080);
container.waitingFor(Wait.forHttp("/test")).start();
ContainerConfig config = container.getContainerInfo().getConfig();
assertLabelsMatchManifestAttributes(config);
ImageAssertions.assertThat(config).buildMetadata((metadata) -> {
metadata.buildpacks()
.contains("paketo-buildpacks/ca-certificates", "paketo-buildpacks/bellsoft-liberica",
"paketo-buildpacks/executable-jar", "paketo-buildpacks/dist-zip",
"paketo-buildpacks/spring-boot");
metadata.processOfType("web")
.containsExactly("java", "org.springframework.boot.loader.launch.JarLauncher");
metadata.processOfType("executable-jar")
.containsExactly("java", "org.springframework.boot.loader.launch.JarLauncher");
});
assertImageHasJvmSbomLayer(imageReference, config);
assertImageHasDependenciesSbomLayer(imageReference, config, "executable-jar");
assertImageLayersMatchLayersIndex(imageReference, config);
}
finally {
removeImage(imageReference);
}
}
@Test
void executableJarAppWithAdditionalArgs() throws Exception {
writeMainClass();
String imageName = "paketo-integration/" + this.gradleBuild.getProjectDir().getName();
ImageReference imageReference = ImageReference.of(ImageName.of(imageName));
BuildResult result = buildImage(imageName);
assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("Running creator");
try (GenericContainer<?> container = new GenericContainer<>(imageName)) {
container.withCommand("--server.port=9090");
container.withExposedPorts(9090);
container.waitingFor(Wait.forHttp("/test")).start();
}
finally {
removeImage(imageReference);
}
}
@Test
void executableJarAppBuiltTwiceWithCaching() throws Exception {
writeMainClass();
String imageName = "paketo-integration/" + this.gradleBuild.getProjectDir().getName();
ImageReference imageReference = ImageReference.of(ImageName.of(imageName));
BuildResult result = buildImage(imageName);
assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("Running creator");
try (GenericContainer<?> container = new GenericContainer<>(imageName)) {
container.withExposedPorts(8080);
container.waitingFor(Wait.forHttp("/test")).start();
container.stop();
}
this.gradleBuild.expectDeprecationMessages("BOM table is deprecated");
result = buildImage(imageName);
assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
try (GenericContainer<?> container = new GenericContainer<>(imageName)) {
container.withExposedPorts(8080);
container.waitingFor(Wait.forHttp("/test")).start();
}
finally {
removeImage(imageReference);
}
}
@Test
@Disabled("0.4.292 of the builder launches an unpacked jar rather than the script in bin")
void bootDistZipJarApp() throws Exception {
writeMainClass();
String projectName = this.gradleBuild.getProjectDir().getName();
String imageName = "paketo-integration/" + projectName;
ImageReference imageReference = ImageReference.of(ImageName.of(imageName));
BuildResult result = buildImage(imageName, "assemble", "bootDistZip");
assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("Running creator");
try (GenericContainer<?> container = new GenericContainer<>(imageName)) {
container.withExposedPorts(8080);
container.waitingFor(Wait.forHttp("/test")).start();
ContainerConfig config = container.getContainerInfo().getConfig();
ImageAssertions.assertThat(config).buildMetadata((metadata) -> {
metadata.buildpacks()
.contains("paketo-buildpacks/ca-certificates", "paketo-buildpacks/bellsoft-liberica",
"paketo-buildpacks/dist-zip", "paketo-buildpacks/spring-boot");
String launcher = "/workspace/" + projectName + "-boot/bin/" + projectName;
metadata.processOfType("web").containsExactly(launcher);
metadata.processOfType("dist-zip").containsExactly(launcher);
});
assertImageHasJvmSbomLayer(imageReference, config);
assertImageHasDependenciesSbomLayer(imageReference, config, "dist-zip");
DigestCapturingCondition digest = new DigestCapturingCondition();
ImageAssertions.assertThat(config)
.lifecycleMetadata((metadata) -> metadata.appLayerShas().haveExactly(1, digest));
ImageAssertions.assertThat(imageReference)
.layer(digest.getDigest(),
(layer) -> layer.entries()
.contains(projectName + "-boot/bin/" + projectName,
projectName + "-boot/lib/" + projectName + ".jar"));
}
finally {
removeImage(imageReference);
}
}
@Test
void plainDistZipJarApp() throws Exception {
writeMainClass();
String projectName = this.gradleBuild.getProjectDir().getName();
String imageName = "paketo-integration/" + projectName;
ImageReference imageReference = ImageReference.of(ImageName.of(imageName));
BuildResult result = buildImage(imageName, "assemble", "bootDistZip");
assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("Running creator");
try (GenericContainer<?> container = new GenericContainer<>(imageName)) {
container.withExposedPorts(8080);
container.waitingFor(Wait.forHttp("/test")).start();
ContainerConfig config = container.getContainerInfo().getConfig();
ImageAssertions.assertThat(config).buildMetadata((metadata) -> {
metadata.buildpacks()
.contains("paketo-buildpacks/ca-certificates", "paketo-buildpacks/bellsoft-liberica",
"paketo-buildpacks/dist-zip", "paketo-buildpacks/spring-boot");
String launcher = "/workspace/" + projectName + "/bin/" + projectName;
metadata.processOfType("web").containsExactly(launcher);
metadata.processOfType("dist-zip").containsExactly(launcher);
});
assertImageHasJvmSbomLayer(imageReference, config);
assertImageHasDependenciesSbomLayer(imageReference, config, "dist-zip");
DigestCapturingCondition digest = new DigestCapturingCondition();
ImageAssertions.assertThat(config)
.lifecycleMetadata((metadata) -> metadata.appLayerShas().haveExactly(1, digest));
ImageAssertions.assertThat(imageReference)
.layer(digest.getDigest(), (layer) -> layer.entries()
.contains(projectName + "/bin/" + projectName, projectName + "/lib/" + projectName + "-plain.jar")
.anyMatch((s) -> s.startsWith(projectName + "/lib/spring-boot-"))
.anyMatch((s) -> s.startsWith(projectName + "/lib/spring-core-"))
.anyMatch((s) -> s.startsWith(projectName + "/lib/spring-web-")));
}
finally {
removeImage(imageReference);
}
}
@Test
void executableWarApp() throws Exception {
writeMainClass();
writeServletInitializerClass();
String imageName = "paketo-integration/" + this.gradleBuild.getProjectDir().getName();
ImageReference imageReference = ImageReference.of(ImageName.of(imageName));
BuildResult result = buildImage(imageName);
assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("Running creator");
try (GenericContainer<?> container = new GenericContainer<>(imageName)) {
container.withExposedPorts(8080);
container.waitingFor(Wait.forHttp("/test")).start();
ContainerConfig config = container.getContainerInfo().getConfig();
assertLabelsMatchManifestAttributes(config);
ImageAssertions.assertThat(config).buildMetadata((metadata) -> {
metadata.buildpacks()
.contains("paketo-buildpacks/ca-certificates", "paketo-buildpacks/bellsoft-liberica",
"paketo-buildpacks/executable-jar", "paketo-buildpacks/dist-zip",
"paketo-buildpacks/spring-boot");
metadata.processOfType("web")
.containsExactly("java", "org.springframework.boot.loader.launch.WarLauncher");
metadata.processOfType("executable-jar")
.containsExactly("java", "org.springframework.boot.loader.launch.WarLauncher");
});
assertImageHasJvmSbomLayer(imageReference, config);
assertImageHasDependenciesSbomLayer(imageReference, config, "executable-jar");
assertImageLayersMatchLayersIndex(imageReference, config);
}
finally {
removeImage(imageReference);
}
}
@Test
void plainWarApp() throws Exception {
writeMainClass();
writeServletInitializerClass();
String imageName = "paketo-integration/" + this.gradleBuild.getProjectDir().getName();
ImageReference imageReference = ImageReference.of(ImageName.of(imageName));
BuildResult result = buildImageWithRetry(imageName);
assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("Running creator");
try (GenericContainer<?> container = new GenericContainer<>(imageName)) {
container.withExposedPorts(8080);
container.waitingFor(Wait.forHttp("/test")).start();
ContainerConfig config = container.getContainerInfo().getConfig();
ImageAssertions.assertThat(config).buildMetadata((metadata) -> {
metadata.buildpacks()
.contains("paketo-buildpacks/ca-certificates", "paketo-buildpacks/bellsoft-liberica",
"paketo-buildpacks/apache-tomcat", "paketo-buildpacks/dist-zip",
"paketo-buildpacks/spring-boot");
metadata.processOfType("web")
.containsSubsequence("sh", "/layers/paketo-buildpacks_apache-tomcat/tomcat/bin/catalina.sh", "run");
metadata.processOfType("tomcat")
.containsSubsequence("sh", "/layers/paketo-buildpacks_apache-tomcat/tomcat/bin/catalina.sh", "run");
});
assertImageHasJvmSbomLayer(imageReference, config);
assertImageHasDependenciesSbomLayer(imageReference, config, "apache-tomcat");
DigestCapturingCondition digest = new DigestCapturingCondition();
ImageAssertions.assertThat(config)
.lifecycleMetadata((metadata) -> metadata.appLayerShas().haveExactly(1, digest));
ImageAssertions.assertThat(imageReference)
.layer(digest.getDigest(),
(layer) -> layer.entries()
.contains("WEB-INF/classes/example/ExampleApplication.class",
"WEB-INF/classes/example/HelloController.class", "META-INF/MANIFEST.MF")
.anyMatch((s) -> s.startsWith("WEB-INF/lib/spring-boot-"))
.anyMatch((s) -> s.startsWith("WEB-INF/lib/spring-core-"))
.anyMatch((s) -> s.startsWith("WEB-INF/lib/spring-web-")));
}
finally {
removeImage(imageReference);
}
}
@Test
void nativeApp() throws Exception {
this.gradleBuild.expectDeprecationMessages("uses or overrides a deprecated API");
this.gradleBuild.expectDeprecationMessages("has been deprecated and marked for removal");
// these deprecations are transitive from the Native Build Tools Gradle plugin
this.gradleBuild
.expectDeprecationMessages("has been deprecated. This is scheduled to be removed in Gradle 9.0");
this.gradleBuild.expectDeprecationMessages("upgrading_version_8.html#deprecated_access_to_convention");
// these deprecations are from native image buildpacks
this.gradleBuild.expectDeprecationMessages("Using a deprecated option --report-unsupported-elements-at-runtime",
"The option is deprecated and will be removed in the future.");
writeMainClass();
String imageName = "paketo-integration/" + this.gradleBuild.getProjectDir().getName();
ImageReference imageReference = ImageReference.of(ImageName.of(imageName));
BuildResult result = buildImage(imageName);
assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("Running creator");
try (GenericContainer<?> container = new GenericContainer<>(imageName)) {
container.withExposedPorts(8080);
container.waitingFor(Wait.forHttp("/test")).start();
ContainerConfig config = container.getContainerInfo().getConfig();
assertLabelsMatchManifestAttributes(config);
ImageAssertions.assertThat(config).buildMetadata((metadata) -> {
metadata.buildpacks()
.contains("paketo-buildpacks/ca-certificates", "paketo-buildpacks/bellsoft-liberica",
"paketo-buildpacks/executable-jar", "paketo-buildpacks/spring-boot",
"paketo-buildpacks/native-image");
metadata.processOfType("web")
.satisfiesExactly((command) -> assertThat(command).endsWith("/example.ExampleApplication"));
metadata.processOfType("native-image")
.satisfiesExactly((command) -> assertThat(command).endsWith("/example.ExampleApplication"));
});
assertImageHasDependenciesSbomLayer(imageReference, config, "native-image");
}
finally {
removeImage(imageReference);
}
}
@Test
void classDataSharingApp() throws Exception {
writeMainClass();
String imageName = "paketo-integration/" + this.gradleBuild.getProjectDir().getName();
ImageReference imageReference = ImageReference.of(ImageName.of(imageName));
BuildResult result = buildImage(imageName);
assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("Running creator");
try (GenericContainer<?> container = new GenericContainer<>(imageName)) {
container.withExposedPorts(8080);
container.waitingFor(Wait.forHttp("/test")).start();
ContainerConfig config = container.getContainerInfo().getConfig();
assertLabelsMatchManifestAttributes(config);
ImageAssertions.assertThat(config).buildMetadata((metadata) -> {
metadata.buildpacks()
.contains("paketo-buildpacks/ca-certificates", "paketo-buildpacks/bellsoft-liberica",
"paketo-buildpacks/executable-jar", "paketo-buildpacks/dist-zip",
"paketo-buildpacks/spring-boot");
metadata.processOfType("web")
.satisfiesExactly((command) -> assertThat(command).isEqualTo("java"),
(arg) -> assertThat(arg).isEqualTo("-cp"),
(arg) -> assertThat(arg).startsWith("runner.jar"),
(arg) -> assertThat(arg).isEqualTo("example.ExampleApplication"));
metadata.processOfType("spring-boot-app")
.satisfiesExactly((command) -> assertThat(command).isEqualTo("java"),
(arg) -> assertThat(arg).isEqualTo("-cp"),
(arg) -> assertThat(arg).startsWith("runner.jar"),
(arg) -> assertThat(arg).isEqualTo("example.ExampleApplication"));
metadata.processOfType("executable-jar")
.containsExactly("java", "org.springframework.boot.loader.launch.JarLauncher");
});
assertImageHasJvmSbomLayer(imageReference, config);
assertImageHasDependenciesSbomLayer(imageReference, config, "executable-jar");
}
finally {
removeImage(imageReference);
}
}
private BuildResult buildImageWithRetry(String imageName, String... arguments) {
long start = System.nanoTime();
while (true) {
try {
return buildImage(imageName, arguments);
}
catch (Exception ex) {
ex.printStackTrace();
if (Duration.ofNanos(System.nanoTime() - start).toMinutes() > 6) {
throw ex;
}
sleep(500);
}
}
}
private void sleep(long time) {
try {
Thread.sleep(time);
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
private BuildResult buildImage(String imageName, String... arguments) {
List<String> args = new ArrayList<>(List.of(arguments));
args.add("bootBuildImage");
args.add("--imageName=" + imageName);
args.add("--pullPolicy=IF_NOT_PRESENT");
return this.gradleBuild.build(args.toArray(new String[0]));
}
private void writeMainClass() throws IOException {
writeProjectFile("ExampleApplication.java", (writer) -> {
writer.println("package example;");
writer.println();
writer.println("import org.springframework.boot.SpringApplication;");
writer.println("import org.springframework.boot.autoconfigure.SpringBootApplication;");
writer.println("import org.springframework.stereotype.Controller;");
writer.println("import org.springframework.web.bind.annotation.RequestMapping;");
writer.println("import org.springframework.web.bind.annotation.ResponseBody;");
writer.println();
writer.println("@SpringBootApplication");
writer.println("public class ExampleApplication {");
writer.println();
writer.println(" public static void main(String[] args) {");
writer.println(" SpringApplication.run(ExampleApplication.class, args);");
writer.println(" }");
writer.println();
writer.println("}");
writer.println();
writer.println("@Controller");
writer.println("class HelloController {");
writer.println();
writer.println(" @RequestMapping(\"/test\")");
writer.println(" @ResponseBody");
writer.println(" String home() {");
writer.println(" return \"Hello, world!\";");
writer.println(" }");
writer.println();
writer.println("}");
});
}
private void writeServletInitializerClass() throws IOException {
writeProjectFile("ServletInitializer.java", (writer) -> {
writer.println("package example;");
writer.println();
writer.println("import org.springframework.boot.builder.SpringApplicationBuilder;");
writer.println("import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;");
writer.println();
writer.println("public class ServletInitializer extends SpringBootServletInitializer {");
writer.println();
writer.println(" @Override");
writer.println(" protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {");
writer.println(" return application.sources(ExampleApplication.class);");
writer.println(" }");
writer.println();
writer.println("}");
});
}
private void writeProjectFile(String fileName, Consumer<PrintWriter> consumer) throws IOException {
File examplePackage = new File(this.gradleBuild.getProjectDir(), "src/main/java/example");
examplePackage.mkdirs();
File main = new File(examplePackage, fileName);
try (PrintWriter writer = new PrintWriter(new FileWriter(main))) {
consumer.accept(writer);
}
}
private void assertLabelsMatchManifestAttributes(ContainerConfig config) throws IOException {
try (JarFile jarFile = new JarFile(projectArchiveFile())) {
Attributes attributes = jarFile.getManifest().getMainAttributes();
ImageAssertions.assertThat(config).labels((labels) -> {
labels.contains(entry("org.springframework.boot.version", attributes.getValue("Spring-Boot-Version")));
labels.contains(entry("org.opencontainers.image.title",
attributes.getValue(Attributes.Name.IMPLEMENTATION_TITLE)));
labels.contains(entry("org.opencontainers.image.version",
attributes.getValue(Attributes.Name.IMPLEMENTATION_VERSION)));
});
}
}
private void assertImageHasJvmSbomLayer(ImageReference imageReference, ContainerConfig config) throws IOException {
DigestCapturingCondition digest = new DigestCapturingCondition();
ImageAssertions.assertThat(config).lifecycleMetadata((metadata) -> metadata.sbomLayerSha().has(digest));
ImageAssertions.assertThat(imageReference).layer(digest.getDigest(), (layer) -> {
layer.entries().contains("/layers/sbom/launch/paketo-buildpacks_bellsoft-liberica/jre/sbom.syft.json");
layer.jsonEntry("/layers/sbom/launch/paketo-buildpacks_bellsoft-liberica/jre/sbom.syft.json", (json) -> {
json.extractingJsonPathStringValue("$.Artifacts[0].Name").isEqualTo("BellSoft Liberica JRE");
json.extractingJsonPathStringValue("$.Artifacts[0].Version").startsWith(javaMajorVersion());
});
});
}
private void assertImageHasDependenciesSbomLayer(ImageReference imageReference, ContainerConfig config,
String buildpack) throws IOException {
DigestCapturingCondition digest = new DigestCapturingCondition();
ImageAssertions.assertThat(config).lifecycleMetadata((metadata) -> metadata.sbomLayerSha().has(digest));
ImageAssertions.assertThat(imageReference).layer(digest.getDigest(), (layer) -> {
layer.entries()
.contains("/layers/sbom/launch/paketo-buildpacks_" + buildpack + "/sbom.syft.json",
"/layers/sbom/launch/paketo-buildpacks_" + buildpack + "/sbom.cdx.json");
layer.jsonEntry("/layers/sbom/launch/paketo-buildpacks_" + buildpack + "/sbom.syft.json",
(json) -> json.extractingJsonPathArrayValue("$.artifacts.[*].name")
.contains("commons-logging", "spring-beans", "spring-boot", "spring-boot-autoconfigure",
"spring-context", "spring-core", "spring-expression", "spring-web", "spring-webmvc"));
layer.jsonEntry("/layers/sbom/launch/paketo-buildpacks_" + buildpack + "/sbom.cdx.json",
(json) -> json.extractingJsonPathArrayValue("$.components.[*].name")
.contains("commons-logging", "spring-beans", "spring-boot", "spring-boot-autoconfigure",
"spring-context", "spring-core", "spring-expression", "spring-web", "spring-webmvc"));
});
}
private void assertImageLayersMatchLayersIndex(ImageReference imageReference, ContainerConfig config)
throws IOException {
DigestsCapturingCondition digests = new DigestsCapturingCondition();
ImageAssertions.assertThat(config)
.lifecycleMetadata((metadata) -> metadata.appLayerShas().haveExactly(5, digests));
LayersIndex layersIndex = LayersIndex.fromArchiveFile(projectArchiveFile());
ImageAssertions.assertThat(imageReference)
.layer(digests.getDigest(0), (layer) -> layer.entries()
.allMatch((entry) -> startsWithOneOf(entry, layersIndex.getLayer("dependencies"))));
ImageAssertions.assertThat(imageReference)
.layer(digests.getDigest(1), (layer) -> layer.entries()
.allMatch((entry) -> startsWithOneOf(entry, layersIndex.getLayer("spring-boot-loader"))));
ImageAssertions.assertThat(imageReference)
.layer(digests.getDigest(2), (layer) -> layer.entries()
.allMatch((entry) -> startsWithOneOf(entry, layersIndex.getLayer("snapshot-dependencies"))));
ImageAssertions.assertThat(imageReference)
.layer(digests.getDigest(3), (layer) -> layer.entries()
.allMatch((entry) -> startsWithOneOf(entry, layersIndex.getLayer("application"))));
ImageAssertions.assertThat(imageReference)
.layer(digests.getDigest(4),
(layer) -> layer.entries().allMatch((entry) -> entry.contains("lib/spring-cloud-bindings-")));
}
private File projectArchiveFile() {
return new File(this.gradleBuild.getProjectDir(), "build/libs").listFiles()[0];
}
private String javaMajorVersion() {
String javaVersion = System.getProperty("java.version");
if (javaVersion.startsWith("1.")) {
return javaVersion.substring(2, 3);
}
int firstDotIndex = javaVersion.indexOf(".");
if (firstDotIndex != -1) {
return javaVersion.substring(0, firstDotIndex);
}
return javaVersion;
}
private boolean startsWithOneOf(String actual, List<String> expectedPrefixes) {
for (String prefix : expectedPrefixes) {
if (actual.startsWith(prefix)) {
return true;
}
}
return false;
}
private void removeImage(ImageReference image) throws IOException {
new DockerApi().image().remove(image, false);
}
private static class DigestCapturingCondition extends Condition<Object> {
private static String digest = null;
DigestCapturingCondition() {
super(predicate(), "a value starting with 'sha256:'");
}
private static Predicate<Object> predicate() {
return (sha) -> {
digest = sha.toString();
return sha.toString().startsWith("sha256:");
};
}
String getDigest() {
return digest;
}
}
private static class DigestsCapturingCondition extends Condition<Object> {
private static List<String> digests;
DigestsCapturingCondition() {
super(predicate(), "a value starting with 'sha256:'");
}
private static Predicate<Object> predicate() {
digests = new ArrayList<>();
return (sha) -> {
digests.add(sha.toString());
return sha.toString().startsWith("sha256:");
};
}
String getDigest(int index) {
return digests.get(index);
}
}
}
@@ -0,0 +1,59 @@
/*
* 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.
*/
plugins {
id 'org.springframework.boot' version '{bootVersion}'
id 'io.spring.dependency-management' version '{dependencyManagementPluginVersion}'
id 'java'
id 'application'
}
repositories {
exclusiveContent {
forRepository {
maven {
url = '{systemTestMavenRepository}'
}
}
filter {
includeGroup "org.springframework.boot"
}
}
mavenCentral()
spring.mavenRepositories()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web:{bootVersion}")
}
bootJar {
manifest {
attributes(
'Implementation-Version': '1.0.0',
'Implementation-Title': "Paketo Test"
)
}
}
application {
mainClass = 'example.ExampleApplication'
}
bootBuildImage {
archiveFile = bootDistZip.archiveFile
environment = ['BP_JVM_VERSION': java.targetCompatibility.getMajorVersion()]
}
@@ -0,0 +1,58 @@
/*
* 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.
*/
plugins {
id 'org.springframework.boot' version '{bootVersion}'
id 'io.spring.dependency-management' version '{dependencyManagementPluginVersion}'
id 'java'
}
repositories {
exclusiveContent {
forRepository {
maven {
url = '{systemTestMavenRepository}'
}
}
filter {
includeGroup "org.springframework.boot"
}
}
mavenCentral()
maven {
url = 'https://repo.spring.io/milestone'
}
maven {
url = 'https://repo.spring.io/snapshot'
}
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web:{bootVersion}")
}
bootJar {
manifest {
attributes(
'Implementation-Version': '1.0.0',
'Implementation-Title': "Paketo Test"
)
}
}
bootBuildImage {
environment = ['BP_JVM_CDS_ENABLED': 'true']
}
@@ -0,0 +1,51 @@
/*
* 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.
*/
plugins {
id 'org.springframework.boot' version '{bootVersion}'
id 'io.spring.dependency-management' version '{dependencyManagementPluginVersion}'
id 'java'
id 'war'
}
repositories {
exclusiveContent {
forRepository {
maven {
url = '{systemTestMavenRepository}'
}
}
filter {
includeGroup "org.springframework.boot"
}
}
mavenCentral()
spring.mavenRepositories()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web:{bootVersion}")
providedRuntime("org.springframework.boot:spring-boot-starter-tomcat:{bootVersion}")
}
bootWar {
manifest {
attributes(
'Implementation-Version': '1.0.0',
'Implementation-Title': "Paketo Test"
)
}
}
@@ -0,0 +1,55 @@
/*
* 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.
*/
plugins {
id 'org.springframework.boot' version '{bootVersion}'
id 'org.springframework.boot.aot' version '{bootVersion}'
id 'io.spring.dependency-management' version '{dependencyManagementPluginVersion}'
id 'org.graalvm.buildtools.native' version '{nativeBuildToolsVersion}'
id 'java'
}
repositories {
exclusiveContent {
forRepository {
maven {
url = '{systemTestMavenRepository}'
}
}
filter {
includeGroup "org.springframework.boot"
}
}
mavenCentral()
spring.mavenRepositories()
}
tasks.named("bootBuildImage") {
environment["BP_JVM_VERSION"] = "24"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web:{bootVersion}")
}
bootJar {
manifest {
attributes(
'Implementation-Version': '1.0.0',
'Implementation-Title': 'Paketo Test'
)
}
}
@@ -0,0 +1,60 @@
/*
* 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.
*/
plugins {
id 'org.springframework.boot' version '{bootVersion}'
id 'io.spring.dependency-management' version '{dependencyManagementPluginVersion}'
id 'java'
id 'application'
}
repositories {
exclusiveContent {
forRepository {
maven {
url = '{systemTestMavenRepository}'
}
}
filter {
includeGroup "org.springframework.boot"
}
}
mavenCentral()
spring.mavenRepositories()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web:{bootVersion}")
}
bootJar {
manifest {
attributes(
'Implementation-Version': '1.0.0',
'Implementation-Title': "Paketo Test"
)
}
}
application {
mainClass = 'example.ExampleApplication'
}
bootBuildImage {
archiveFile = distZip.archiveFile
runImage = "paketobuildpacks/ubuntu-noble-run-base:latest"
environment = ['BP_JVM_VERSION': java.targetCompatibility.getMajorVersion()]
}
@@ -0,0 +1,52 @@
/*
* 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.
*/
plugins {
id 'org.springframework.boot' version '{bootVersion}'
id 'io.spring.dependency-management' version '{dependencyManagementPluginVersion}'
id 'java'
id 'war'
}
repositories {
exclusiveContent {
forRepository {
maven {
url = '{systemTestMavenRepository}'
}
}
filter {
includeGroup "org.springframework.boot"
}
}
mavenCentral()
spring.mavenRepositories()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web:{bootVersion}")
}
war {
enabled = true
archiveClassifier.set('plain')
}
bootBuildImage {
archiveFile = war.archiveFile
runImage = "paketobuildpacks/ubuntu-noble-run-base:latest"
environment = ['BP_JVM_VERSION': java.targetCompatibility.getMajorVersion(), 'BP_TOMCAT_VERSION': '10.*']
}
@@ -0,0 +1,49 @@
/*
* 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.
*/
plugins {
id 'org.springframework.boot' version '{bootVersion}'
id 'io.spring.dependency-management' version '{dependencyManagementPluginVersion}'
id 'java'
}
repositories {
exclusiveContent {
forRepository {
maven {
url = '{systemTestMavenRepository}'
}
}
filter {
includeGroup "org.springframework.boot"
}
}
mavenCentral()
spring.mavenRepositories()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web:{bootVersion}")
}
bootJar {
manifest {
attributes(
'Implementation-Version': '1.0.0',
'Implementation-Title': "Paketo Test"
)
}
}
@@ -0,0 +1,40 @@
/*
* 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.
*/
pluginManagement {
evaluate(new File("{parentRootDir}/buildSrc/SpringRepositorySupport.groovy")).apply(this)
repositories {
exclusiveContent {
forRepository {
maven {
url = '{systemTestMavenRepository}'
}
}
filter {
includeGroup "org.springframework.boot"
}
}
spring.mavenRepositories()
gradlePluginPortal()
}
resolutionStrategy {
eachPlugin {
if (requested.id.id.startsWith("org.springframework.boot")) {
useModule "org.springframework.boot:spring-boot-gradle-plugin:${requested.version}"
}
}
}
}