Commit 0bd0b2a6 authored by Andy Wilkinson's avatar Andy Wilkinson

Add support for building OCI images using the Gradle plugin

Closes gh-19831
parent bc452bc0
......@@ -35,8 +35,9 @@ import org.springframework.util.StreamUtils;
* Adapter class to convert a ZIP file to a {@link TarArchive}.
*
* @author Phillip Webb
* @since 2.3.0
*/
class ZipFileTarArchive implements TarArchive {
public class ZipFileTarArchive implements TarArchive {
static final long NORMALIZED_MOD_TIME = TarArchive.NORMALIZED_TIME.toEpochMilli();
......@@ -44,7 +45,13 @@ class ZipFileTarArchive implements TarArchive {
private final Owner owner;
ZipFileTarArchive(File zip, Owner owner) {
/**
* Creates an archive from the contents of the given {@code zip}. Each entry in the
* archive will be owned by the given {@code owner}.
* @param zip the zip to use as a source
* @param owner the owner of the tar entries
*/
public ZipFileTarArchive(File zip, Owner owner) {
Assert.notNull(zip, "Zip must not be null");
Assert.notNull(owner, "Owner must not be null");
this.zip = zip;
......
......@@ -30,6 +30,7 @@ dependencies {
asciidoctorExtensions("io.spring.asciidoctor:spring-asciidoctor-extensions-block-switch:0.3.0.RELEASE")
implementation(project(":spring-boot-project:spring-boot-tools:spring-boot-buildpack-platform"))
implementation(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-tools"))
implementation("io.spring.gradle:dependency-management-plugin")
implementation("org.apache.commons:commons-compress")
......@@ -38,9 +39,11 @@ dependencies {
optional(platform(project(":spring-boot-project:spring-boot-dependencies")))
optional("org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.50")
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support"))
testImplementation("org.assertj:assertj-core")
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core")
testImplementation("org.testcontainers:testcontainers")
}
gradlePlugin {
......
......@@ -26,6 +26,7 @@ Andy Wilkinson
:api-documentation: {spring-boot-docs}/gradle-plugin/api
:spring-boot-reference: {spring-boot-docs}/reference/htmlsingle
:build-info-javadoc: {api-documentation}/org/springframework/boot/gradle/tasks/buildinfo/BuildInfo.html
:boot-build-image-javadoc: {api-documentation}/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.html
:boot-jar-javadoc: {api-documentation}/org/springframework/boot/gradle/tasks/bundling/BootJar.html
:boot-war-javadoc: {api-documentation}/org/springframework/boot/gradle/tasks/bundling/BootWar.html
:boot-run-javadoc: {api-documentation}/org/springframework/boot/gradle/tasks/run/BootRun.html
......
......@@ -298,3 +298,12 @@ The jar will then be split into layer folders which may include:
* `resources`
* `snapshots-dependencies`
* `dependencies`
[[packaging-oci-images]]
== Packaging OCI images
The plugin can create OCI images from executable jars using a https://buildpacks.io[buildpack].
Images can be built using the `bootBuildImage` task and a local Docker installation.
The task is automatically created when the `java` plugin is applied and is an instance of {boot-build-image-javadoc}[`BootBuildImage`].
......@@ -15,10 +15,11 @@ When Gradle's {java-plugin}[`java` plugin] is applied to a project, the Spring B
The jar will contain everything on the runtime classpath of the main source set; classes are packaged in `BOOT-INF/classes` and jars are packaged in `BOOT-INF/lib`
2. Configures the `assemble` task to depend on the `bootJar` task.
3. Disables the `jar` task.
4. Creates a {boot-run-javadoc}[`BootRun`] task named `bootRun` that can be used to run your application.
5. Creates a configuration named `bootArchives` that contains the artifact produced by the `bootJar` task.
6. Configures any `JavaCompile` tasks with no configured encoding to use `UTF-8`.
7. Configures any `JavaCompile` tasks to use the `-parameters` compiler argument.
4. Creates a {boot-build-image-javadoc}[`BootBuildImage`] task named `bootBuildImage` that will create a OCI image using a https://buildpacks.io[buildpack].
5. Creates a {boot-run-javadoc}[`BootRun`] task named `bootRun` that can be used to run your application.
6. Creates a configuration named `bootArchives` that contains the artifact produced by the `bootJar` task.
7. Configures any `JavaCompile` tasks with no configured encoding to use `UTF-8`.
8. Configures any `JavaCompile` tasks to use the `-parameters` compiler argument.
......
......@@ -36,6 +36,7 @@ import org.gradle.api.plugins.JavaPluginConvention;
import org.gradle.api.tasks.SourceSet;
import org.gradle.api.tasks.compile.JavaCompile;
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage;
import org.springframework.boot.gradle.tasks.bundling.BootJar;
import org.springframework.boot.gradle.tasks.run.BootRun;
import org.springframework.util.StringUtils;
......@@ -65,6 +66,7 @@ final class JavaPluginAction implements PluginApplicationAction {
disableJarTask(project);
configureBuildTask(project);
BootJar bootJar = configureBootJarTask(project);
configureBootBuildImageTask(project, bootJar);
configureArtifactPublication(bootJar);
configureBootRunTask(project);
configureUtf8Encoding(project);
......@@ -94,6 +96,14 @@ final class JavaPluginAction implements PluginApplicationAction {
return bootJar;
}
private void configureBootBuildImageTask(Project project, BootJar bootJar) {
BootBuildImage buildImage = project.getTasks().create(SpringBootPlugin.BOOT_BUILD_IMAGE_TASK_NAME,
BootBuildImage.class);
buildImage.setDescription("Builds an OCI image of the application using the output of the bootJar task");
buildImage.setGroup(BasePlugin.BUILD_GROUP);
buildImage.from(bootJar);
}
private void configureArtifactPublication(BootJar bootJar) {
ArchivePublishArtifact artifact = new ArchivePublishArtifact(bootJar);
this.singlePublishedArtifact.addCandidate(artifact);
......
......@@ -34,6 +34,7 @@ import org.gradle.api.artifacts.ResolvableDependencies;
import org.gradle.util.GradleVersion;
import org.springframework.boot.gradle.dsl.SpringBootExtension;
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage;
import org.springframework.boot.gradle.tasks.bundling.BootJar;
import org.springframework.boot.gradle.tasks.bundling.BootWar;
......@@ -68,6 +69,12 @@ public class SpringBootPlugin implements Plugin<Project> {
*/
public static final String BOOT_WAR_TASK_NAME = "bootWar";
/**
* The name of the default {@link BootBuildImage} task.
* @since 2.3.0
*/
public static final String BOOT_BUILD_IMAGE_TASK_NAME = "bootBuildImage";
/**
* The coordinates {@code (group:name:version)} of the
* {@code spring-boot-dependencies} bom.
......
/*
* Copyright 2012-2020 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.gradle.tasks.bundling;
import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
import org.gradle.api.DefaultTask;
import org.gradle.api.Project;
import org.gradle.api.Task;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.Optional;
import org.gradle.api.tasks.TaskAction;
import org.springframework.boot.buildpack.platform.build.BuildRequest;
import org.springframework.boot.buildpack.platform.build.Builder;
import org.springframework.boot.buildpack.platform.docker.DockerException;
import org.springframework.boot.buildpack.platform.docker.type.ImageName;
import org.springframework.boot.buildpack.platform.docker.type.ImageReference;
import org.springframework.boot.buildpack.platform.io.ZipFileTarArchive;
import org.springframework.util.StringUtils;
/**
* A {@link Task} for bundling an application into an OCI image using a
* <a href="https://buildpacks.io">buildpack</a>.
*
* @author Andy Wilkinson
* @since 2.3.0
*/
public class BootBuildImage extends DefaultTask {
private Supplier<File> jar;
private String imageName;
private String builder;
private Map<String, String> environment = new HashMap<String, String>();
private boolean cleanCache;
private boolean verboseLogging;
/**
* Configures this task to create an image from the given {@code bootJar} task. This
* task is also configured to depend upon the given task.
* @param bootJar the fat jar from which the image should be created.
*/
public void from(BootJar bootJar) {
dependsOn(bootJar);
this.jar = () -> bootJar.getArchiveFile().get().getAsFile();
}
/**
* Configures this task to create an image from the given jar file.
* @param jar the jar from which the image should be created.
*/
public void from(File jar) {
this.jar = () -> jar;
}
/**
* Returns the name of the image that will be built. When {@code null}, the name will
* be derived from the {@link Project Project's} {@link Project#getName() name} and
* {@link Project#getVersion version}.
* @return name of the image
*/
@Input
@Optional
public String getImageName() {
return this.imageName;
}
/**
* Sets the name of the image that will be built.
* @param imageName name of the image
*/
public void setImageName(String imageName) {
this.imageName = imageName;
}
/**
* Returns the builder that will be used to build the image. When {@code null}, the
* default builder will be used.
* @return the builder
*/
@Input
@Optional
public String getBuilder() {
return this.builder;
}
/**
* Sets the builder that will be used to build the image.
* @param builder the builder
*/
public void setBuilder(String builder) {
this.builder = builder;
}
/**
* Returns the environment that will be used when building the image.
* @return the environment
*/
@Input
public Map<String, String> getEnvironment() {
return this.environment;
}
/**
* Sets the environment that will be used when building the image.
* @param environment the environment
*/
public void setEnvironment(Map<String, String> environment) {
this.environment = environment;
}
/**
* Add an entry to the environment that will be used when building the image.
* @param name the name of the entry
* @param value the value of the entry
*/
public void environment(String name, String value) {
this.environment.put(name, value);
}
/**
* Adds entries to the environment that will be used when building the image.
* @param entries the entries to add to the environment
*/
public void environment(Map<String, String> entries) {
this.environment.putAll(entries);
}
/**
* Returns whether caches should be cleaned before packaging.
* @return whether caches should be cleaned
*/
@Input
public boolean isCleanCache() {
return this.cleanCache;
}
/**
* Sets whether caches should be cleaned before packaging.
* @param cleanCache {@code true} to clean the cache, otherwise {@code false}.
*/
public void setCleanCache(boolean cleanCache) {
this.cleanCache = cleanCache;
}
/**
* Whether verbose logging should be enabled while building the image.
* @return whether verbose logging should be enabled
*/
@Input
public boolean isVerboseLogging() {
return this.verboseLogging;
}
/**
* Sets whether verbose logging should be enabled while building the image.
* @param verboseLogging {@code true} to enable verbose logging, otherwise
* {@code false}.
*/
public void setVerboseLogging(boolean verboseLogging) {
this.verboseLogging = verboseLogging;
}
@TaskAction
void buildImage() throws DockerException, IOException {
Builder builder = new Builder();
BuildRequest request = createRequest();
builder.build(request);
}
BuildRequest createRequest() {
BuildRequest request = customize(
BuildRequest.of(determineImageReference(), (owner) -> new ZipFileTarArchive(this.jar.get(), owner)));
return request;
}
private ImageReference determineImageReference() {
if (StringUtils.hasText(this.imageName)) {
return ImageReference.of(this.imageName);
}
ImageName imageName = ImageName.of(getProject().getName());
String version = getProject().getVersion().toString();
if ("unspecified".equals(version)) {
return ImageReference.of(imageName);
}
return ImageReference.of(imageName, version);
}
private BuildRequest customize(BuildRequest request) {
if (StringUtils.hasText(this.builder)) {
request = request.withBuilder(ImageReference.of(this.builder));
}
if (this.environment != null && !this.environment.isEmpty()) {
request = request.withEnv(this.environment);
}
request = request.withCleanCache(this.cleanCache);
request = request.withVerboseLogging(this.verboseLogging);
return request;
}
}
/*
* Copyright 2012-2020 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.gradle.tasks.bundling;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import org.gradle.testkit.runner.BuildResult;
import org.gradle.testkit.runner.TaskOutcome;
import org.junit.jupiter.api.TestTemplate;
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.gradle.junit.GradleCompatibilityExtension;
import org.springframework.boot.gradle.testkit.GradleBuild;
import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link BootBuildImage}.
*
* @author Andy Wilkinson
*/
@ExtendWith(GradleCompatibilityExtension.class)
@DisabledIfDockerUnavailable
class BootBuildImageIntegrationTests {
GradleBuild gradleBuild;
@TestTemplate
void bootBuildImageBuildsImage() throws IOException {
writeMainClass();
BuildResult result = this.gradleBuild.build("bootBuildImage");
assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
ImageReference imageReference = ImageReference.of(ImageName.of(this.gradleBuild.getProjectDir().getName()));
try (GenericContainer<?> container = new GenericContainer<>(imageReference.toString())) {
container.waitingFor(Wait.forLogMessage("Launched\\n", 1)).start();
}
finally {
new DockerApi().image().remove(imageReference, false);
}
}
private void writeMainClass() {
File examplePackage = new File(this.gradleBuild.getProjectDir(), "src/main/java/example");
examplePackage.mkdirs();
File main = new File(examplePackage, "Main.java");
try (PrintWriter writer = new PrintWriter(new FileWriter(main))) {
writer.println("package example;");
writer.println();
writer.println("import java.io.IOException;");
writer.println();
writer.println("public class Main {");
writer.println();
writer.println(" public static void main(String[] args) throws Exception {");
writer.println(" System.out.println(\"Launched\");");
writer.println(" synchronized(args) {");
writer.println(" args.wait(); // Prevent exit");
writer.println(" }");
writer.println(" }");
writer.println();
writer.println("}");
}
catch (IOException ex) {
throw new RuntimeException(ex);
}
}
}
/*
* Copyright 2012-2020 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.gradle.tasks.bundling;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
import org.gradle.api.Project;
import org.gradle.testfixtures.ProjectBuilder;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.buildpack.platform.build.BuildRequest;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link BootBuildImage}.
*
* @author Andy Wilkinson
*/
class BootBuildImageTests {
@TempDir
File temp;
Project project;
private final BootBuildImage buildImage;
BootBuildImageTests() {
File projectDir = new File(this.temp, "project");
projectDir.mkdirs();
this.project = ProjectBuilder.builder().withProjectDir(projectDir).withName("build-image-test").build();
this.project.setDescription("Test project for BootBuildImage");
this.buildImage = this.project.getTasks().create("buildImage", BootBuildImage.class);
}
@Test
void whenProjectVersionIsUnspecifiedThenItIsIgnoredWhenDerivingImageName() {
BuildRequest request = this.buildImage.createRequest();
assertThat(request.getName().getDomain()).isEqualTo("docker.io");
assertThat(request.getName().getName()).isEqualTo("library/build-image-test");
assertThat(request.getName().getTag()).isEqualTo("latest");
assertThat(request.getName().getDigest()).isNull();
}
@Test
void whenProjectVersionIsSpecifiedThenItIsUsedInTagOfImageName() {
this.project.setVersion("1.2.3");
BuildRequest request = this.buildImage.createRequest();
assertThat(request.getName().getDomain()).isEqualTo("docker.io");
assertThat(request.getName().getName()).isEqualTo("library/build-image-test");
assertThat(request.getName().getTag()).isEqualTo("1.2.3");
assertThat(request.getName().getDigest()).isNull();
}
@Test
void whenImageNameIsSpecifiedThenItIsUsedInRequest() {
this.project.setVersion("1.2.3");
this.buildImage.setImageName("example.com/test/build-image:1.0");
BuildRequest request = this.buildImage.createRequest();
assertThat(request.getName().getDomain()).isEqualTo("example.com");
assertThat(request.getName().getName()).isEqualTo("test/build-image");
assertThat(request.getName().getTag()).isEqualTo("1.0");
assertThat(request.getName().getDigest()).isNull();
}
@Test
void whenIndividualEntriesAreAddedToTheEnvironmentThenTheyAreIncludedInTheRequest() {
this.buildImage.environment("ALPHA", "a");
this.buildImage.environment("BRAVO", "b");
assertThat(this.buildImage.createRequest().getEnv()).containsEntry("ALPHA", "a").containsEntry("BRAVO", "b")
.hasSize(2);
}
@Test
void whenEntriesAreAddedToTheEnvironmentThenTheyAreIncludedInTheRequest() {
Map<String, String> environment = new HashMap<String, String>();
environment.put("ALPHA", "a");
environment.put("BRAVO", "b");
this.buildImage.environment(environment);
assertThat(this.buildImage.createRequest().getEnv()).containsEntry("ALPHA", "a").containsEntry("BRAVO", "b")
.hasSize(2);
}
@Test
void whenTheEnvironmentIsSetItIsIncludedInTheRequest() {
Map<String, String> environment = new HashMap<String, String>();
environment.put("ALPHA", "a");
environment.put("BRAVO", "b");
this.buildImage.setEnvironment(environment);
assertThat(this.buildImage.createRequest().getEnv()).containsEntry("ALPHA", "a").containsEntry("BRAVO", "b")
.hasSize(2);
}
@Test
void whenTheEnvironmentIsSetItReplacesAnyExistingEntriesAndIsIncludedInTheRequest() {
Map<String, String> environment = new HashMap<String, String>();
environment.put("ALPHA", "a");
environment.put("BRAVO", "b");
this.buildImage.environment("C", "Charlie");
this.buildImage.setEnvironment(environment);
assertThat(this.buildImage.createRequest().getEnv()).containsEntry("ALPHA", "a").containsEntry("BRAVO", "b")
.hasSize(2);
}
@Test
void whenUsingDefaultConfigurationThenRequestHasVerboseLoggingDisabled() {
assertThat(this.buildImage.createRequest().isVerboseLogging()).isFalse();
}
@Test
void whenVerboseLoggingIsEnabledThenRequestHasVerboseLoggingEnabled() {
this.buildImage.setVerboseLogging(true);
assertThat(this.buildImage.createRequest().isVerboseLogging()).isTrue();
}
@Test
void whenUsingDefaultConfigurationThenRequestHasCleanCacheDisabled() {
assertThat(this.buildImage.createRequest().isCleanCache()).isFalse();
}
@Test
void whenCleanCacheIsEnabledThenRequestHasCleanCacheEnabled() {
this.buildImage.setCleanCache(true);
assertThat(this.buildImage.createRequest().isCleanCache()).isTrue();
}
@Test
void whenNoBuilderIsConfiguredThenRequestHasDefaultBuilder() {
assertThat(this.buildImage.createRequest().getBuilder().getName()).isEqualTo("cloudfoundry/cnb");
}
@Test
void whenBuilderIsConfiguredThenRequestUsesSpecifiedBuilder() {
this.buildImage.setBuilder("example.com/test/builder:1.2");
assertThat(this.buildImage.createRequest().getBuilder().getName()).isEqualTo("test/builder");
}
}
......@@ -27,9 +27,16 @@ import java.util.Arrays;
import java.util.List;
import java.util.jar.JarFile;
import com.fasterxml.jackson.annotation.JsonView;
import com.fasterxml.jackson.core.Versioned;
import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import com.sun.jna.Platform;
import io.spring.gradle.dependencymanagement.DependencyManagementPlugin;
import io.spring.gradle.dependencymanagement.dsl.DependencyManagementExtension;
import org.apache.commons.compress.archivers.ArchiveEntry;
import org.apache.http.HttpRequest;
import org.apache.http.conn.HttpClientConnectionManager;
import org.gradle.testkit.runner.BuildResult;
import org.gradle.testkit.runner.GradleRunner;
import org.gradle.util.GradleVersion;
......@@ -41,6 +48,7 @@ import org.jetbrains.kotlin.gradle.plugin.KotlinGradleSubplugin;
import org.jetbrains.kotlin.gradle.plugin.KotlinPlugin;
import org.springframework.asm.ClassVisitor;
import org.springframework.boot.buildpack.platform.build.BuildRequest;
import org.springframework.boot.loader.tools.LaunchScript;
import org.springframework.util.FileCopyUtils;
import org.springframework.util.FileSystemUtils;
......@@ -94,7 +102,12 @@ public class GradleBuild {
new File(pathOfJarContaining(KotlinPlugin.class)), new File(pathOfJarContaining(KotlinProject.class)),
new File(pathOfJarContaining(KotlinCompilerClient.class)),
new File(pathOfJarContaining(KotlinGradleSubplugin.class)),
new File(pathOfJarContaining(ArchiveEntry.class)));
new File(pathOfJarContaining(ArchiveEntry.class)), new File(pathOfJarContaining(BuildRequest.class)),
new File(pathOfJarContaining(HttpClientConnectionManager.class)),
new File(pathOfJarContaining(HttpRequest.class)), new File(pathOfJarContaining(Module.class)),
new File(pathOfJarContaining(Versioned.class)),
new File(pathOfJarContaining(ParameterNamesModule.class)),
new File(pathOfJarContaining(JsonView.class)), new File(pathOfJarContaining(Platform.class)));
}
private String pathOfJarContaining(Class<?> type) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment