diff --git a/jib-core/CHANGELOG.md b/jib-core/CHANGELOG.md index 59c426eb4d..5f5d6a03f6 100644 --- a/jib-core/CHANGELOG.md +++ b/jib-core/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Added - Adds support for configuring volumes ([#1121](https://github.com/GoogleContainerTools/jib/issues/1121)) +- Adds `JavaContainerBuilder` for building opinionated containers for Java applications ([#1212](https://github.com/GoogleContainerTools/jib/issues/1212)) ### Changed diff --git a/jib-core/src/main/java/com/google/cloud/tools/jib/api/JavaContainerBuilder.java b/jib-core/src/main/java/com/google/cloud/tools/jib/api/JavaContainerBuilder.java new file mode 100644 index 0000000000..106eeaaef0 --- /dev/null +++ b/jib-core/src/main/java/com/google/cloud/tools/jib/api/JavaContainerBuilder.java @@ -0,0 +1,357 @@ +/* + * Copyright 2018 Google LLC. + * + * 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 + * + * http://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 com.google.cloud.tools.jib.api; + +import com.google.cloud.tools.jib.filesystem.AbsoluteUnixPath; +import com.google.cloud.tools.jib.frontend.JavaEntrypointConstructor; +import com.google.cloud.tools.jib.frontend.JavaLayerConfigurations; +import com.google.cloud.tools.jib.frontend.JavaLayerConfigurations.LayerType; +import com.google.cloud.tools.jib.frontend.MainClassFinder; +import com.google.cloud.tools.jib.image.ImageReference; +import com.google.cloud.tools.jib.image.InvalidImageReferenceException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.NotDirectoryException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map.Entry; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import javax.annotation.Nullable; + +/** Creates a {@link JibContainerBuilder} for containerizing Java applications. */ +public class JavaContainerBuilder { + + /** The default root directory of the application on the container. */ + private static final AbsoluteUnixPath APP_ROOT = AbsoluteUnixPath.get("/app"); + + /** Absolute path of directory containing application resources on container. */ + private static final AbsoluteUnixPath RESOURCES_PATH = + APP_ROOT.resolve(JavaEntrypointConstructor.DEFAULT_RELATIVE_RESOURCES_PATH_ON_IMAGE); + + /** Absolute path of directory containing classes on container. */ + private static final AbsoluteUnixPath CLASSES_PATH = + APP_ROOT.resolve(JavaEntrypointConstructor.DEFAULT_RELATIVE_CLASSES_PATH_ON_IMAGE); + + /** Absolute path of directory containing dependencies on container. */ + private static final AbsoluteUnixPath DEPENDENCIES_PATH = + APP_ROOT.resolve(JavaEntrypointConstructor.DEFAULT_RELATIVE_DEPENDENCIES_PATH_ON_IMAGE); + + /** The entrypoint classpath element corresponding to dependencies. */ + private static final AbsoluteUnixPath DEPENDENCIES_CLASSPATH = DEPENDENCIES_PATH.resolve("*"); + + /** Absolute path of directory containing additional classpath files on container. */ + private static final AbsoluteUnixPath OTHERS_PATH = APP_ROOT.resolve("classpath"); + + /** + * Creates a new {@link JavaContainerBuilder} that uses distroless java as the base image. For + * more information on {@code gcr.io/distroless/java}, see the distroless repository. + * + * @return a new {@link JavaContainerBuilder} + * @throws InvalidImageReferenceException if creating the base image reference fails + * @see The distroless repository + */ + public static JavaContainerBuilder fromDistroless() throws InvalidImageReferenceException { + return from(RegistryImage.named("gcr.io/distroless/java")); + } + + /** + * Creates a new {@link JavaContainerBuilder} with the specified base image reference. + * + * @param baseImageReference the base image reference + * @return a new {@link JavaContainerBuilder} + * @throws InvalidImageReferenceException if {@code baseImageReference} is invalid + */ + public static JavaContainerBuilder from(String baseImageReference) + throws InvalidImageReferenceException { + return from(RegistryImage.named(baseImageReference)); + } + + /** + * Creates a new {@link JavaContainerBuilder} with the specified base image reference. + * + * @param baseImageReference the base image reference + * @return a new {@link JavaContainerBuilder} + */ + public static JavaContainerBuilder from(ImageReference baseImageReference) { + return from(RegistryImage.named(baseImageReference)); + } + + /** + * Creates a new {@link JavaContainerBuilder} with the specified base image. + * + * @param registryImage the {@link RegistryImage} that defines base container registry and + * credentials + * @return a new {@link JavaContainerBuilder} + */ + public static JavaContainerBuilder from(RegistryImage registryImage) { + return new JavaContainerBuilder(Jib.from(registryImage)); + } + + private final JibContainerBuilder jibContainerBuilder; + private final JavaLayerConfigurations.Builder layerConfigurationsBuilder = + JavaLayerConfigurations.builder(); + private final List jvmFlags = new ArrayList<>(); + private final LinkedHashSet classpath = new LinkedHashSet<>(4); + + @Nullable private String mainClass; + + private JavaContainerBuilder(JibContainerBuilder jibContainerBuilder) { + this.jibContainerBuilder = jibContainerBuilder; + } + + /** + * Adds dependency JARs to the image. Duplicate JAR filenames are renamed with the filesize in + * order to avoid collisions. + * + * @param dependencyFiles the list of dependency JARs to add to the image + * @return this + * @throws IOException if adding the layer fails + */ + public JavaContainerBuilder addDependencies(List dependencyFiles) throws IOException { + // Make sure all files exist before adding any + for (Path file : dependencyFiles) { + if (!Files.exists(file)) { + throw new NoSuchFileException(file.toString()); + } + } + + // Detect duplicate filenames and rename with filesize to avoid collisions + List duplicates = + dependencyFiles + .stream() + .map(Path::getFileName) + .map(Path::toString) + .collect(Collectors.groupingBy(filename -> filename, Collectors.counting())) + .entrySet() + .stream() + .filter(entry -> entry.getValue() > 1) + .map(Entry::getKey) + .collect(Collectors.toList()); + for (Path file : dependencyFiles) { + layerConfigurationsBuilder.addFile( + file.getFileName().toString().contains("SNAPSHOT") + ? LayerType.SNAPSHOT_DEPENDENCIES + : LayerType.DEPENDENCIES, + file, + DEPENDENCIES_PATH.resolve( + duplicates.contains(file.getFileName().toString()) + ? file.getFileName().toString().replaceFirst("\\.jar$", "-" + Files.size(file)) + + ".jar" + : file.getFileName().toString())); + } + classpath.add(DEPENDENCIES_CLASSPATH.toString()); + return this; + } + + /** + * Adds dependency JARs to the image. Duplicate JAR filenames are renamed with the filesize in + * order to avoid collisions. + * + * @param dependencyFiles the list of dependency JARs to add to the image + * @return this + * @throws IOException if adding the layer fails + */ + public JavaContainerBuilder addDependencies(Path... dependencyFiles) throws IOException { + return addDependencies(Arrays.asList(dependencyFiles)); + } + + /** + * Adds the contents of a resources directory to the image. + * + * @param resourceFilesDirectory the directory containing the project's resources + * @return this + * @throws IOException if adding the layer fails + */ + public JavaContainerBuilder addResources(Path resourceFilesDirectory) throws IOException { + return addResources(resourceFilesDirectory, path -> true); + } + + /** + * Adds the contents of a resources directory to the image. + * + * @param resourceFilesDirectory the directory containing the project's resources + * @param pathFilter filter that determines which files (not directories) should be added + * @return this + * @throws IOException if adding the layer fails + */ + public JavaContainerBuilder addResources(Path resourceFilesDirectory, Predicate pathFilter) + throws IOException { + return addDirectory(resourceFilesDirectory, RESOURCES_PATH, LayerType.RESOURCES, pathFilter); + } + + /** + * Adds the contents of a classes directory to the image. + * + * @param classFilesDirectory the directory containing the class files + * @return this + * @throws IOException if adding the layer fails + */ + public JavaContainerBuilder addClasses(Path classFilesDirectory) throws IOException { + return addClasses(classFilesDirectory, path -> true); + } + + /** + * Adds the contents of a classes directory to the image. + * + * @param classFilesDirectory the directory containing the class files + * @param pathFilter filter that determines which files (not directories) should be added + * @return this + * @throws IOException if adding the layer fails + */ + public JavaContainerBuilder addClasses(Path classFilesDirectory, Predicate pathFilter) + throws IOException { + return addDirectory(classFilesDirectory, CLASSES_PATH, LayerType.CLASSES, pathFilter); + } + + /** + * Adds additional files to the classpath. If {@code otherFiles} contains a directory, the files + * within are added recursively, maintaining the directory structure. For files in {@code + * otherFiles}, files with duplicate filenames will be overwritten (e.g. if {@code otherFiles} + * contains '/loser/messages.txt' and '/winner/messages.txt', only the second 'messages.txt' is + * added. + * + * @param otherFiles the list of files to add + * @return this + * @throws IOException if adding the layer fails + */ + public JavaContainerBuilder addToClasspath(List otherFiles) throws IOException { + // Make sure all files exist before adding any + for (Path file : otherFiles) { + if (!Files.exists(file)) { + throw new NoSuchFileException(file.toString()); + } + } + + for (Path file : otherFiles) { + if (Files.isDirectory(file)) { + layerConfigurationsBuilder.addDirectoryContents( + LayerType.EXTRA_FILES, file, path -> true, OTHERS_PATH); + } else { + layerConfigurationsBuilder.addFile( + LayerType.EXTRA_FILES, file, OTHERS_PATH.resolve(file.getFileName())); + } + } + classpath.add(OTHERS_PATH.toString()); + return this; + } + + /** + * Adds additional files to the classpath. If {@code otherFiles} contains a directory, the files + * within are added recursively, maintaining the directory structure. For files in {@code + * otherFiles}, files with duplicate filenames will be overwritten (e.g. if {@code otherFiles} + * contains '/loser/messages.txt' and '/winner/messages.txt', only the second 'messages.txt' is + * added. + * + * @param otherFiles the list of files to add + * @return this + * @throws IOException if adding the layer fails + */ + public JavaContainerBuilder addToClasspath(Path... otherFiles) throws IOException { + return addToClasspath(Arrays.asList(otherFiles)); + } + + /** + * Adds a JVM flag to use when starting the application. + * + * @param jvmFlag the JVM flag to add + * @return this + */ + public JavaContainerBuilder addJvmFlag(String jvmFlag) { + jvmFlags.add(jvmFlag); + return this; + } + + /** + * Adds JVM flags to use when starting the application. + * + * @param jvmFlags the list of JVM flags to add + * @return this + */ + public JavaContainerBuilder addJvmFlags(List jvmFlags) { + this.jvmFlags.addAll(jvmFlags); + return this; + } + + /** + * Adds JVM flags to use when starting the application. + * + * @param jvmFlags the list of JVM flags to add + * @return this + */ + public JavaContainerBuilder addJvmFlags(String... jvmFlags) { + this.jvmFlags.addAll(Arrays.asList(jvmFlags)); + return this; + } + + /** + * Sets the main class used to start the application on the image. To find the main class from + * {@code .class} files, use {@link MainClassFinder}. + * + * @param mainClass the main class used to start the application + * @return this + * @see MainClassFinder + */ + public JavaContainerBuilder setMainClass(String mainClass) { + this.mainClass = mainClass; + return this; + } + + /** + * Returns a new {@link JibContainerBuilder} using the parameters specified on the {@link + * JavaContainerBuilder}. + * + * @return a new {@link JibContainerBuilder} using the parameters specified on the {@link + * JavaContainerBuilder} + */ + public JibContainerBuilder toContainerBuilder() { + if (mainClass == null) { + throw new IllegalStateException( + "mainClass is null on JavaContainerBuilder; specify the main class using " + + "JavaContainerBuilder#setMainClass(String), or consider using a " + + "jib.frontend.MainClassFinder to infer the main class"); + } + if (classpath.isEmpty()) { + throw new IllegalStateException( + "Failed to construct entrypoint because no files were added to the JavaContainerBuilder"); + } + + jibContainerBuilder.setEntrypoint( + JavaEntrypointConstructor.makeEntrypoint(new ArrayList<>(classpath), jvmFlags, mainClass)); + jibContainerBuilder.setLayers(layerConfigurationsBuilder.build().getLayerConfigurations()); + return jibContainerBuilder; + } + + private JavaContainerBuilder addDirectory( + Path directory, AbsoluteUnixPath destination, LayerType layerType, Predicate pathFilter) + throws IOException { + if (!Files.exists(directory)) { + throw new NoSuchFileException(directory.toString()); + } + if (!Files.isDirectory(directory)) { + throw new NotDirectoryException(directory.toString()); + } + layerConfigurationsBuilder.addDirectoryContents(layerType, directory, pathFilter, destination); + classpath.add(destination.toString()); + return this; + } +} diff --git a/jib-core/src/main/java/com/google/cloud/tools/jib/frontend/JavaLayerConfigurations.java b/jib-core/src/main/java/com/google/cloud/tools/jib/frontend/JavaLayerConfigurations.java index de7a206ff3..8a9dd1f593 100644 --- a/jib-core/src/main/java/com/google/cloud/tools/jib/frontend/JavaLayerConfigurations.java +++ b/jib-core/src/main/java/com/google/cloud/tools/jib/frontend/JavaLayerConfigurations.java @@ -160,7 +160,6 @@ public Builder addDirectoryContents( * @throws IOException error while listing directories * @throws NotDirectoryException if {@code sourceRoot} is not a directory */ - // TODO: Use in plugins public Builder addDirectoryContents( LayerType layerType, Path sourceRoot, diff --git a/jib-core/src/test/java/com/google/cloud/tools/jib/api/JavaContainerBuilderTest.java b/jib-core/src/test/java/com/google/cloud/tools/jib/api/JavaContainerBuilderTest.java new file mode 100644 index 0000000000..46493641f3 --- /dev/null +++ b/jib-core/src/test/java/com/google/cloud/tools/jib/api/JavaContainerBuilderTest.java @@ -0,0 +1,222 @@ +/* + * Copyright 2018 Google LLC. + * + * 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 + * + * http://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 com.google.cloud.tools.jib.api; + +import com.google.cloud.tools.jib.configuration.BuildConfiguration; +import com.google.cloud.tools.jib.configuration.CacheDirectoryCreationException; +import com.google.cloud.tools.jib.configuration.ContainerConfiguration; +import com.google.cloud.tools.jib.filesystem.AbsoluteUnixPath; +import com.google.cloud.tools.jib.image.InvalidImageReferenceException; +import com.google.cloud.tools.jib.image.LayerEntry; +import com.google.common.collect.ImmutableList; +import com.google.common.io.Resources; +import com.google.common.util.concurrent.MoreExecutors; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.Assert; +import org.junit.Test; + +/** Tests for {@link JavaContainerBuilder}. */ +public class JavaContainerBuilderTest { + + /** Gets a resource file as a {@link Path}. */ + private static Path getResource(String directory) throws URISyntaxException { + return Paths.get(Resources.getResource(directory).toURI()); + } + + /** Gets the extraction paths in the specified layer of a give {@link BuildConfiguration}. */ + private static List getExtractionPaths( + BuildConfiguration buildConfiguration, String layerName) { + return buildConfiguration + .getLayerConfigurations() + .stream() + .filter(layerConfiguration -> layerConfiguration.getName().equals(layerName)) + .findFirst() + .map( + layerConfiguration -> + layerConfiguration + .getLayerEntries() + .stream() + .map(LayerEntry::getExtractionPath) + .collect(Collectors.toList())) + .orElse(ImmutableList.of()); + } + + @Test + public void testToJibContainerBuilder_all() + throws InvalidImageReferenceException, URISyntaxException, IOException, + CacheDirectoryCreationException { + BuildConfiguration buildConfiguration = + JavaContainerBuilder.fromDistroless() + .addResources(getResource("application/resources")) + .addClasses(getResource("application/classes")) + .addDependencies( + getResource("application/dependencies/dependency-1.0.0.jar"), + getResource("application/dependencies/more/dependency-1.0.0.jar"), + getResource("application/dependencies/libraryA.jar"), + getResource("application/dependencies/libraryB.jar"), + getResource("application/snapshot-dependencies/dependency-1.0.0-SNAPSHOT.jar")) + .addToClasspath(getResource("fileA"), getResource("fileB")) + .addJvmFlags("-xflag1", "-xflag2") + .setMainClass("HelloWorld") + .toContainerBuilder() + .toBuildConfiguration( + Containerizer.to(RegistryImage.named("hello")), + MoreExecutors.newDirectExecutorService()); + + // Check entrypoint + ContainerConfiguration containerConfiguration = buildConfiguration.getContainerConfiguration(); + Assert.assertNotNull(containerConfiguration); + Assert.assertEquals( + ImmutableList.of( + "java", + "-xflag1", + "-xflag2", + "-cp", + "/app/resources:/app/classes:/app/libs/*:/app/classpath", + "HelloWorld"), + containerConfiguration.getEntrypoint()); + + // Check dependencies + List expectedDependencies = + ImmutableList.of( + AbsoluteUnixPath.get("/app/libs/dependency-1.0.0-770.jar"), + AbsoluteUnixPath.get("/app/libs/dependency-1.0.0-200.jar"), + AbsoluteUnixPath.get("/app/libs/libraryA.jar"), + AbsoluteUnixPath.get("/app/libs/libraryB.jar")); + Assert.assertEquals( + expectedDependencies, getExtractionPaths(buildConfiguration, "dependencies")); + + // Check snapshots + List expectedSnapshotDependencies = + ImmutableList.of(AbsoluteUnixPath.get("/app/libs/dependency-1.0.0-SNAPSHOT.jar")); + Assert.assertEquals( + expectedSnapshotDependencies, + getExtractionPaths(buildConfiguration, "snapshot dependencies")); + + // Check resources + List expectedResources = + ImmutableList.of( + AbsoluteUnixPath.get("/app/resources/resourceA"), + AbsoluteUnixPath.get("/app/resources/resourceB"), + AbsoluteUnixPath.get("/app/resources/world")); + Assert.assertEquals(expectedResources, getExtractionPaths(buildConfiguration, "resources")); + + // Check classes + List expectedClasses = + ImmutableList.of( + AbsoluteUnixPath.get("/app/classes/HelloWorld.class"), + AbsoluteUnixPath.get("/app/classes/some.class")); + Assert.assertEquals(expectedClasses, getExtractionPaths(buildConfiguration, "classes")); + + // Check additional classpath files + List expectedOthers = + ImmutableList.of( + AbsoluteUnixPath.get("/app/classpath/fileA"), + AbsoluteUnixPath.get("/app/classpath/fileB")); + Assert.assertEquals(expectedOthers, getExtractionPaths(buildConfiguration, "extra files")); + } + + @Test + public void testToJibContainerBuilder_missingAndMultipleAdds() + throws InvalidImageReferenceException, URISyntaxException, IOException, + CacheDirectoryCreationException { + BuildConfiguration buildConfiguration = + JavaContainerBuilder.fromDistroless() + .addDependencies(getResource("application/dependencies/libraryA.jar")) + .addDependencies(getResource("application/dependencies/libraryB.jar")) + .addDependencies( + getResource("application/snapshot-dependencies/dependency-1.0.0-SNAPSHOT.jar")) + .addClasses(getResource("application/classes/")) + .addClasses(getResource("class-finder-tests/extension")) + .setMainClass("HelloWorld") + .toContainerBuilder() + .toBuildConfiguration( + Containerizer.to(RegistryImage.named("hello")), + MoreExecutors.newDirectExecutorService()); + + // Check entrypoint + ContainerConfiguration containerConfiguration = buildConfiguration.getContainerConfiguration(); + Assert.assertNotNull(containerConfiguration); + Assert.assertEquals( + ImmutableList.of("java", "-cp", "/app/libs/*:/app/classes", "HelloWorld"), + containerConfiguration.getEntrypoint()); + + // Check dependencies + List expectedDependencies = + ImmutableList.of( + AbsoluteUnixPath.get("/app/libs/libraryA.jar"), + AbsoluteUnixPath.get("/app/libs/libraryB.jar")); + Assert.assertEquals( + expectedDependencies, getExtractionPaths(buildConfiguration, "dependencies")); + + // Check snapshots + List expectedSnapshotDependencies = + ImmutableList.of(AbsoluteUnixPath.get("/app/libs/dependency-1.0.0-SNAPSHOT.jar")); + Assert.assertEquals( + expectedSnapshotDependencies, + getExtractionPaths(buildConfiguration, "snapshot dependencies")); + + // Check classes + List expectedClasses = + ImmutableList.of( + AbsoluteUnixPath.get("/app/classes/HelloWorld.class"), + AbsoluteUnixPath.get("/app/classes/some.class"), + AbsoluteUnixPath.get("/app/classes/main/"), + AbsoluteUnixPath.get("/app/classes/main/MainClass.class"), + AbsoluteUnixPath.get("/app/classes/pack/"), + AbsoluteUnixPath.get("/app/classes/pack/Apple.class"), + AbsoluteUnixPath.get("/app/classes/pack/Orange.class")); + Assert.assertEquals(expectedClasses, getExtractionPaths(buildConfiguration, "classes")); + + // Check empty layers + Assert.assertEquals(ImmutableList.of(), getExtractionPaths(buildConfiguration, "resources")); + Assert.assertEquals(ImmutableList.of(), getExtractionPaths(buildConfiguration, "extra files")); + } + + @Test + public void testToJibContainerBuilder_mainClassNull() throws InvalidImageReferenceException { + try { + JavaContainerBuilder.fromDistroless().toContainerBuilder(); + Assert.fail(); + + } catch (IllegalStateException ex) { + Assert.assertEquals( + "mainClass is null on JavaContainerBuilder; specify the main class using " + + "JavaContainerBuilder#setMainClass(String), or consider using a " + + "jib.frontend.MainClassFinder to infer the main class", + ex.getMessage()); + } + } + + @Test + public void testToJibContainerBuilder_classpathEmpty() throws InvalidImageReferenceException { + try { + JavaContainerBuilder.fromDistroless().setMainClass("Hello").toContainerBuilder(); + Assert.fail(); + + } catch (IllegalStateException ex) { + Assert.assertEquals( + "Failed to construct entrypoint because no files were added to the JavaContainerBuilder", + ex.getMessage()); + } + } +} diff --git a/jib-core/src/test/resources/application/dependencies/more/dependency-1.0.0.jar b/jib-core/src/test/resources/application/dependencies/more/dependency-1.0.0.jar new file mode 100644 index 0000000000..4d14054965 --- /dev/null +++ b/jib-core/src/test/resources/application/dependencies/more/dependency-1.0.0.jar @@ -0,0 +1,2 @@ +]e$Ềx, +.3I݅38VKAM)=5~'qю$[- :&% Eo7Ns`iZ0MT.9J[}?\E }UvJdo(i"Mԛ_+/cI.-O=Hi \ No newline at end of file