diff --git a/CHANGELOG.md b/CHANGELOG.md index eab020aeeb..9253c19975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ Usage: ./scripts/extract-changelog-for-version.sh 1.3.37 5 ``` ### 1.14-SNAPSHOT -* Fix #2353: Add condition and alias to HelmDependency model +* Fix #1674: SpringBootGenerator utilizes the layered jar if present and use it as Docker layers * Fix #1713: Add HelidonHealthCheckEnricher to add Kubernetes health checks for Helidon applications * Fix #1714: Add HelidonGenerator to add opinionated container image for Helidon applications * Fix #1929: Docker Image Name parsing fix @@ -39,6 +39,7 @@ Usage: * Fix #2302 Bump Kubernetes Client version to 6.8.0 * Fix #2324: Update SpringBootConfigurationHelper for Spring Boot 3.x * Fix #2350: Enrichers with NAME configuration override fragments with default names +* Fix #2353: Add condition and alias to HelmDependency model ### 1.13.1 (2023-06-16) * Fix #2212: Bump Kubernetes Client version to 6.7.2 (fixes issues when trace-logging OpenShift builds -regression in 6.7.1-) diff --git a/gradle-plugin/it/src/test/java/org/eclipse/jkube/gradle/plugin/tests/SpringBootIT.java b/gradle-plugin/it/src/test/java/org/eclipse/jkube/gradle/plugin/tests/SpringBootIT.java index 5ce5719574..833070790f 100644 --- a/gradle-plugin/it/src/test/java/org/eclipse/jkube/gradle/plugin/tests/SpringBootIT.java +++ b/gradle-plugin/it/src/test/java/org/eclipse/jkube/gradle/plugin/tests/SpringBootIT.java @@ -13,7 +13,9 @@ */ package org.eclipse.jkube.gradle.plugin.tests; +import java.io.File; import java.io.IOException; +import java.nio.file.Files; import org.eclipse.jkube.kit.common.ResourceVerify; @@ -26,7 +28,30 @@ class SpringBootIT { @RegisterExtension - private final ITGradleRunnerExtension gradleRunner = new ITGradleRunnerExtension(); + protected final ITGradleRunnerExtension gradleRunner = new ITGradleRunnerExtension(); + + @Test + void k8sBuild_whenRunWithJibBuildStrategy_generatesLayeredImage() throws IOException { + // When + final BuildResult result = gradleRunner.withITProject("spring-boot") + .withArguments("clean", "build", "k8sBuild", "-Pjkube.build.strategy=jib", "--stacktrace") + .build(); + // Then + final File dockerFile = gradleRunner.resolveFile("build", "docker", "gradle", "spring-boot", "latest", "build", "Dockerfile"); + assertThat(new String(Files.readAllBytes(dockerFile.toPath()))) + .contains("FROM quay.io/jkube/jkube-java:") + .contains("ENV JAVA_MAIN_CLASS=org.springframework.boot.loader.JarLauncher JAVA_APP_DIR=/deployments") + .contains("EXPOSE 8080 8778 9779") + .contains("COPY /dependencies/deployments /deployments/") + .contains("COPY /spring-boot-loader/deployments /deployments/") + .contains("COPY /application/deployments /deployments/") + .contains("WORKDIR /deployments") + .contains("ENTRYPOINT [\"java\",\"org.springframework.boot.loader.JarLauncher\"]"); + assertThat(result).extracting(BuildResult::getOutput).asString() + .contains("Running generator spring-boot") + .contains("Spring Boot layered jar detected") + .contains("JIB image build started"); + } @Test void k8sResource_whenRun_generatesK8sManifests() throws IOException, ParseException { @@ -66,4 +91,5 @@ void ocResource_whenRun_generatesOpenShiftManifests() throws IOException, ParseE .contains("jkube-service-discovery: Using first mentioned service port '8080' ") .contains("jkube-revision-history: Adding revision history limit to 2"); } + } diff --git a/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/ExternalCommand.java b/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/ExternalCommand.java index 8bb2033c09..74fafbf077 100644 --- a/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/ExternalCommand.java +++ b/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/ExternalCommand.java @@ -32,7 +32,6 @@ /** * @author roland - * @since 14/09/16 */ public abstract class ExternalCommand { protected final KitLogger log; diff --git a/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/util/SpringBootUtil.java b/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/util/SpringBootUtil.java index 6e9408ebee..02db0a6208 100644 --- a/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/util/SpringBootUtil.java +++ b/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/util/SpringBootUtil.java @@ -13,12 +13,15 @@ */ package org.eclipse.jkube.kit.common.util; +import java.io.File; +import java.io.IOException; import java.net.URL; import java.net.URLClassLoader; import java.util.Collections; import java.util.Map; import java.util.Optional; import java.util.Properties; +import java.util.jar.JarFile; import org.eclipse.jkube.kit.common.JavaProject; import org.eclipse.jkube.kit.common.Plugin; @@ -119,5 +122,13 @@ public static boolean isSpringBootRepackage(JavaProject project) { .map(e -> e.contains("repackage")) .orElse(false); } + + public static boolean isLayeredJar(File fatJar) { + try (JarFile jarFile = new JarFile(fatJar)) { + return jarFile.getEntry("BOOT-INF/layers.idx") != null; + } catch (IOException ioException) { + throw new IllegalStateException("Failure in inspecting fat jar for layers.idx file", ioException); + } + } } diff --git a/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/util/SpringBootUtilTest.java b/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/util/SpringBootUtilTest.java index b5db038b12..948b551764 100644 --- a/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/util/SpringBootUtilTest.java +++ b/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/util/SpringBootUtilTest.java @@ -25,13 +25,19 @@ import java.io.IOException; import java.net.URL; import java.net.URLClassLoader; +import java.nio.file.Files; import java.util.Arrays; import java.util.Collections; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Properties; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -264,4 +270,30 @@ void isSpringBootRepackage_whenNoExecution_thenReturnFalse() { // Then assertThat(result).isFalse(); } + + @Test + void isLayeredJar_whenInvalidFile_thenThrowException() { + // When + Then + assertThatIllegalStateException() + .isThrownBy(() -> SpringBootUtil.isLayeredJar(new File("i-dont-exist.jar"))) + .withMessage("Failure in inspecting fat jar for layers.idx file"); + } + + @Test + void isLayeredJar_whenJarContainsLayers_thenReturnTrue(@TempDir File temporaryFolder) throws IOException { + // Given + File jarFile = new File(temporaryFolder, "fat.jar"); + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + manifest.getMainAttributes().put(Attributes.Name.MAIN_CLASS, "org.example.Foo"); + try (JarOutputStream jarOutputStream = new JarOutputStream(Files.newOutputStream(jarFile.toPath()), manifest)) { + jarOutputStream.putNextEntry(new JarEntry("BOOT-INF/layers.idx")); + } + + // When + boolean result = SpringBootUtil.isLayeredJar(jarFile); + + // Then + assertThat(result).isTrue(); + } } diff --git a/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/SpringBootLayeredJar.java b/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/SpringBootLayeredJar.java new file mode 100644 index 0000000000..40025985cb --- /dev/null +++ b/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/SpringBootLayeredJar.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2019 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at: + * + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.jkube.springboot; + +import lombok.Getter; +import org.eclipse.jkube.kit.common.ExternalCommand; +import org.eclipse.jkube.kit.common.KitLogger; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class SpringBootLayeredJar { + + private final File layeredJar; + private final KitLogger kitLogger; + + public SpringBootLayeredJar(File layeredJar, KitLogger kitLogger) { + this.layeredJar = layeredJar; + this.kitLogger = kitLogger; + } + + public List listLayers() { + final LayerListCommand layerListCommand = new LayerListCommand(kitLogger, layeredJar); + try { + layerListCommand.execute(); + return layerListCommand.getLayers(); + } catch (IOException ioException) { + throw new IllegalStateException("Failure in getting spring boot jar layers information", ioException); + } + } + + public void extractLayers(File extractionDir) { + try { + new LayerExtractorCommand(kitLogger, extractionDir, layeredJar).execute(); + } catch (IOException ioException) { + throw new IllegalStateException("Failure in extracting spring boot jar layers", ioException); + } + } + + private static class LayerExtractorCommand extends ExternalCommand { + private final File layeredJar; + protected LayerExtractorCommand(KitLogger log, File workDir, File layeredJar) { + super(log, workDir); + this.layeredJar = layeredJar; + } + + @Override + protected String[] getArgs() { + return new String[] { "java", "-Djarmode=layertools", "-jar", layeredJar.getAbsolutePath(), "extract"}; + } + } + + private static class LayerListCommand extends ExternalCommand { + private final File layeredJar; + @Getter + private final List layers; + + protected LayerListCommand(KitLogger log, File layeredJar) { + super(log); + this.layeredJar = layeredJar; + layers = new ArrayList<>(); + } + + @Override + protected String[] getArgs() { + return new String[] { "java", "-Djarmode=layertools", "-jar", layeredJar.getAbsolutePath(), "list"}; + } + + @Override + protected void processLine(String line) { + layers.add(line); + } + + } +} diff --git a/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/generator/AbstractSpringBootNestedGenerator.java b/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/generator/AbstractSpringBootNestedGenerator.java index 6cd1134d68..a46f8c5618 100644 --- a/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/generator/AbstractSpringBootNestedGenerator.java +++ b/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/generator/AbstractSpringBootNestedGenerator.java @@ -17,6 +17,7 @@ import org.eclipse.jkube.generator.api.GeneratorContext; import org.eclipse.jkube.generator.javaexec.JavaExecGenerator; import org.eclipse.jkube.kit.common.JavaProject; +import org.eclipse.jkube.kit.common.KitLogger; public abstract class AbstractSpringBootNestedGenerator implements SpringBootNestedGenerator { @@ -42,4 +43,8 @@ public String getBuildWorkdir() { public String getTargetDir() { return generatorConfig.get(JavaExecGenerator.Config.TARGET_DIR); } + + protected KitLogger getLogger() { + return generatorContext.getLogger(); + } } diff --git a/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/generator/LayeredJarGenerator.java b/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/generator/LayeredJarGenerator.java new file mode 100644 index 0000000000..6015deacdd --- /dev/null +++ b/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/generator/LayeredJarGenerator.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2019 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at: + * + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.jkube.springboot.generator; + +import org.eclipse.jkube.generator.api.GeneratorConfig; +import org.eclipse.jkube.generator.api.GeneratorContext; +import org.eclipse.jkube.kit.common.Arguments; +import org.eclipse.jkube.kit.common.Assembly; +import org.eclipse.jkube.kit.common.AssemblyConfiguration; +import org.eclipse.jkube.kit.common.AssemblyFileSet; +import org.eclipse.jkube.springboot.SpringBootLayeredJar; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.eclipse.jkube.kit.common.util.FileUtil.getRelativePath; + +public class LayeredJarGenerator extends AbstractSpringBootNestedGenerator { + + private static final String MAIN_CLASS = "org.springframework.boot.loader.JarLauncher"; + private final SpringBootLayeredJar springBootLayeredJar; + + public LayeredJarGenerator(GeneratorContext generatorContext, GeneratorConfig generatorConfig, File layeredJar) { + super(generatorContext, generatorConfig); + springBootLayeredJar = new SpringBootLayeredJar(layeredJar, getLogger()); + } + + @Override + public Arguments getBuildEntryPoint() { + return Arguments.builder() + .exec(Arrays.asList("java", MAIN_CLASS)) + .build(); + } + + @Override + public Map getEnv() { + return Collections.singletonMap("JAVA_MAIN_CLASS", MAIN_CLASS); + } + + @Override + public AssemblyConfiguration createAssemblyConfiguration(List defaultFileSets) { + getLogger().info("Spring Boot layered jar detected"); + final List layerAssemblies = new ArrayList<>(); + layerAssemblies.add(Assembly.builder().id("jkube-includes").fileSets(defaultFileSets).build()); + springBootLayeredJar.extractLayers(getProject().getBuildPackageDirectory()); + + for (String springBootLayer : springBootLayeredJar.listLayers()) { + File layerDir = new File(getProject().getBuildPackageDirectory(), springBootLayer); + layerAssemblies.add(Assembly.builder() + .id(springBootLayer) + .fileSet(AssemblyFileSet.builder() + .outputDirectory(new File(".")) + .directory(getRelativePath(getProject().getBaseDirectory(), layerDir)) + .exclude("*") + .fileMode("0640") + .build()) + .build()); + } + + return AssemblyConfiguration.builder() + .targetDir(getTargetDir()) + .excludeFinalOutputArtifact(true) + .layers(layerAssemblies) + .build(); + } +} diff --git a/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/generator/SpringBootGenerator.java b/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/generator/SpringBootGenerator.java index 0ab95e44fe..1e485e236c 100644 --- a/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/generator/SpringBootGenerator.java +++ b/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/generator/SpringBootGenerator.java @@ -57,7 +57,7 @@ public enum Config implements Configs.Config { public SpringBootGenerator(GeneratorContext context) { super(context, "spring-boot"); - nestedGenerator = SpringBootNestedGenerator.from(context, getGeneratorConfig()); + nestedGenerator = SpringBootNestedGenerator.from(context, getGeneratorConfig(), detectFatJar()); } @Override @@ -95,6 +95,7 @@ protected Map getEnv(boolean prePackagePhase) { res.put(SpringBootUtil.DEV_TOOLS_REMOTE_SECRET_ENV, secret); } } + res.putAll(nestedGenerator.getEnv()); return res; } @@ -124,7 +125,7 @@ protected String getDefaultWebPort() { @Override protected AssemblyConfiguration createAssembly() { - return Optional.ofNullable(nestedGenerator.createAssemblyConfiguration()) + return Optional.ofNullable(nestedGenerator.createAssemblyConfiguration(addAdditionalFiles())) .orElse(super.createAssembly()); } diff --git a/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/generator/SpringBootNestedGenerator.java b/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/generator/SpringBootNestedGenerator.java index 68b5c5f3e1..a9c31231bf 100644 --- a/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/generator/SpringBootNestedGenerator.java +++ b/jkube-kit/jkube-kit-spring-boot/src/main/java/org/eclipse/jkube/springboot/generator/SpringBootNestedGenerator.java @@ -15,17 +15,24 @@ import org.eclipse.jkube.generator.api.GeneratorConfig; import org.eclipse.jkube.generator.api.GeneratorContext; +import org.eclipse.jkube.generator.javaexec.FatJarDetector; import org.eclipse.jkube.kit.common.Arguments; import org.eclipse.jkube.kit.common.AssemblyConfiguration; +import org.eclipse.jkube.kit.common.AssemblyFileSet; import org.eclipse.jkube.kit.common.JavaProject; +import java.util.Collections; +import java.util.List; +import java.util.Map; + import static org.eclipse.jkube.generator.javaexec.JavaExecGenerator.JOLOKIA_PORT_DEFAULT; import static org.eclipse.jkube.generator.javaexec.JavaExecGenerator.PROMETHEUS_PORT_DEFAULT; +import static org.eclipse.jkube.kit.common.util.SpringBootUtil.isLayeredJar; public interface SpringBootNestedGenerator { JavaProject getProject(); - default AssemblyConfiguration createAssemblyConfiguration() { + default AssemblyConfiguration createAssemblyConfiguration(List defaultFileSets) { return null; } @@ -49,7 +56,15 @@ default Arguments getBuildEntryPoint() { String getTargetDir(); - static SpringBootNestedGenerator from(GeneratorContext generatorContext, GeneratorConfig generatorConfig) { + default Map getEnv() { + return Collections.emptyMap(); + } + + static SpringBootNestedGenerator from(GeneratorContext generatorContext, GeneratorConfig generatorConfig, FatJarDetector.Result fatJarDetectorResult) { + if (fatJarDetectorResult != null && fatJarDetectorResult.getArchiveFile() != null && + isLayeredJar(fatJarDetectorResult.getArchiveFile())) { + return new LayeredJarGenerator(generatorContext, generatorConfig, fatJarDetectorResult.getArchiveFile()); + } return new FatJarGenerator(generatorContext, generatorConfig); } } diff --git a/jkube-kit/jkube-kit-spring-boot/src/test/java/org/eclipse/jkube/springboot/SpringBootLayeredJarTest.java b/jkube-kit/jkube-kit-spring-boot/src/test/java/org/eclipse/jkube/springboot/SpringBootLayeredJarTest.java new file mode 100644 index 0000000000..42df0da0d7 --- /dev/null +++ b/jkube-kit/jkube-kit-spring-boot/src/test/java/org/eclipse/jkube/springboot/SpringBootLayeredJarTest.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2019 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at: + * + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.jkube.springboot; + +import org.eclipse.jkube.kit.common.KitLogger; +import org.eclipse.jkube.kit.common.assertj.FileAssertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +class SpringBootLayeredJarTest { + + @TempDir + private File projectDir; + + private SpringBootLayeredJar springBootLayeredJar; + + @Nested + @DisplayName("with invalid jar") + class InvalidJar { + @BeforeEach + void setup() { + springBootLayeredJar = new SpringBootLayeredJar(new File(projectDir, "invalid.jar"), new KitLogger.SilentLogger()); + } + + @Test + void listLayers_whenJarInvalid_thenThrowException() { + assertThatIllegalStateException() + .isThrownBy(() -> springBootLayeredJar.listLayers()) + .withMessage("Failure in getting spring boot jar layers information"); + } + + @Test + void extractLayers_whenJarInvalid_thenThrowException() { + assertThatIllegalStateException() + .isThrownBy(() -> springBootLayeredJar.extractLayers(projectDir)) + .withMessage("Failure in extracting spring boot jar layers"); + } + } + @Nested + @DisplayName("with valid jar") + class ValidJar { + @BeforeEach + void setup() throws IOException { + final File layeredJar = new File(projectDir, "layered.jar"); + Files.copy( + Objects.requireNonNull(SpringBootLayeredJarTest.class.getResourceAsStream("/generator-integration-test/layered-jar.jar")), + new File(projectDir, "layered.jar").toPath() + ); + springBootLayeredJar = new SpringBootLayeredJar(layeredJar, new KitLogger.SilentLogger()); + } + + @Test + void listLayers_whenJarInvalid_thenThrowException() { + // When + final List result = springBootLayeredJar.listLayers(); + // Then + assertThat(result) + .containsExactly("dependencies", "spring-boot-loader", "snapshot-dependencies", "application"); + } + + @Test + void extractLayers_whenJarInvalid_thenThrowException() throws IOException { + // Given + final File extractionDir = Files.createDirectory(new File(projectDir, "extracted").toPath()).toFile(); + // When + springBootLayeredJar.extractLayers(extractionDir); + // Then + FileAssertions.assertThat(extractionDir) + .fileTree() + .contains("dependencies", "spring-boot-loader", "snapshot-dependencies", "application"); + } + } +} diff --git a/jkube-kit/jkube-kit-spring-boot/src/test/java/org/eclipse/jkube/springboot/generator/SpringBootGeneratorIntegrationTest.java b/jkube-kit/jkube-kit-spring-boot/src/test/java/org/eclipse/jkube/springboot/generator/SpringBootGeneratorIntegrationTest.java index 60633943c3..9419ba76a9 100644 --- a/jkube-kit/jkube-kit-spring-boot/src/test/java/org/eclipse/jkube/springboot/generator/SpringBootGeneratorIntegrationTest.java +++ b/jkube-kit/jkube-kit-spring-boot/src/test/java/org/eclipse/jkube/springboot/generator/SpringBootGeneratorIntegrationTest.java @@ -16,9 +16,9 @@ import org.assertj.core.api.InstanceOfAssertFactories; import org.eclipse.jkube.generator.api.GeneratorContext; import org.eclipse.jkube.generator.api.GeneratorMode; -import org.eclipse.jkube.generator.javaexec.FatJarDetector; import org.eclipse.jkube.kit.common.Assembly; import org.eclipse.jkube.kit.common.AssemblyConfiguration; +import org.eclipse.jkube.kit.common.AssemblyFileSet; import org.eclipse.jkube.kit.common.Dependency; import org.eclipse.jkube.kit.common.JavaProject; import org.eclipse.jkube.kit.common.KitLogger; @@ -27,9 +27,9 @@ import org.eclipse.jkube.kit.config.image.build.BuildConfiguration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.mockito.MockedConstruction; import java.io.File; import java.io.IOException; @@ -39,25 +39,25 @@ import java.util.List; import java.util.Objects; import java.util.Properties; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockConstruction; -import static org.mockito.Mockito.when; class SpringBootGeneratorIntegrationTest { + + private static final String SPRING_VERSION = "2.7.2"; private File targetDir; private Properties properties; - @TempDir - Path temporaryFolder; - private GeneratorContext context; @BeforeEach - void setUp() throws IOException { + void setUp(@TempDir Path temporaryFolder) throws IOException { properties = new Properties(); targetDir = Files.createDirectory(temporaryFolder.resolve("target")).toFile(); - JavaProject javaProject = JavaProject.builder() + final JavaProject javaProject = JavaProject.builder() .baseDirectory(temporaryFolder.toFile()) .buildDirectory(targetDir.getAbsoluteFile()) .buildPackageDirectory(targetDir.getAbsoluteFile()) @@ -67,12 +67,12 @@ void setUp() throws IOException { .dependency(Dependency.builder() .groupId("org.springframework.boot") .artifactId("spring-boot-web") - .version("2.7.2") + .version(SPRING_VERSION) .build()) .plugin(Plugin.builder() .groupId("org.springframework.boot") .artifactId("spring-boot-maven-plugin") - .version("2.7.2") + .version(SPRING_VERSION) .build()) .buildFinalName("sample") .build(); @@ -82,225 +82,312 @@ void setUp() throws IOException { .build(); } - @Test - @DisplayName("customize, with standard packaging, has standard image name") - void customize_withStandardPackaging_thenImageNameContainsGroupArtifactAndLatestTag() { - // Given - withCustomMainClass(); - SpringBootGenerator springBootGenerator = new SpringBootGenerator(context); - List images = new ArrayList<>(); - - // When - images = springBootGenerator.customize(images, false); + @Nested + @DisplayName("With fat Jar packaging") + class StandardPackaging { + + @BeforeEach + void standardFatJar() { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + manifest.getMainAttributes().put(Attributes.Name.MAIN_CLASS, "org.example.Foo"); + try (JarOutputStream jarOutputStream = new JarOutputStream(Files.newOutputStream( + targetDir.toPath().resolve("fat.jar")), manifest)) { + jarOutputStream.putNextEntry(new JarEntry("META-INF/")); + jarOutputStream.putNextEntry(new JarEntry("org/")); + jarOutputStream.putNextEntry(new JarEntry("org/springframework/")); + jarOutputStream.putNextEntry(new JarEntry("org/springframework/boot/")); + jarOutputStream.putNextEntry(new JarEntry("org/springframework/boot/loader/")); + jarOutputStream.putNextEntry(new JarEntry("org/springframework/boot/loader/ClassPathIndexFile.class")); + jarOutputStream.putNextEntry(new JarEntry("org/springframework/boot/loader/JarLauncher.class")); + jarOutputStream.putNextEntry(new JarEntry("BOOT-INF/")); + jarOutputStream.putNextEntry(new JarEntry("BOOT-INF/classes/")); + jarOutputStream.putNextEntry(new JarEntry("BOOT-INF/classpath.idx")); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } - // Then - assertThat(images) + @Test + @DisplayName("has image name (group/artifact:latest)") + void imageNameContainsGroupArtifactAndLatestTag() { + // When + final List images = new SpringBootGenerator(context) + .customize(new ArrayList<>(), false); + // Then + assertThat(images) .singleElement(InstanceOfAssertFactories.type(ImageConfiguration.class)) .hasFieldOrPropertyWithValue("name", "%g/%a:%l"); - } - - @Test - @DisplayName("customize, with standard packaging, has 'spring-boot' image alias") - void customize_withStandardPackaging_thenImageAliasSpringBoot() { - // Given - withCustomMainClass(); - SpringBootGenerator springBootGenerator = new SpringBootGenerator(context); - List images = new ArrayList<>(); - - // When - images = springBootGenerator.customize(images, false); + } - // Then - assertThat(images) + @Test + @DisplayName("has 'spring-boot' image alias") + void imageAliasSpringBoot() { + // When + final List images = new SpringBootGenerator(context) + .customize(new ArrayList<>(), false); + // Then + assertThat(images) .singleElement(InstanceOfAssertFactories.type(ImageConfiguration.class)) .hasFieldOrPropertyWithValue("alias", "spring-boot"); - } - - @Test - @DisplayName("customize, with standard packaging, has image from based on standard Java Exec generator image") - void customize_withStandardPackaging_hasFrom() { - // Given - withCustomMainClass(); - SpringBootGenerator springBootGenerator = new SpringBootGenerator(context); - List images = new ArrayList<>(); - - // When - images = springBootGenerator.customize(images, false); + } - // Then - assertThat(images) + @Test + @DisplayName("has image from based on standard Java Exec generator image") + void hasFrom() { + // When + final List images = new SpringBootGenerator(context) + .customize(new ArrayList<>(), false); + // Then + assertThat(images) .singleElement(InstanceOfAssertFactories.type(ImageConfiguration.class)) .extracting(ImageConfiguration::getBuild) .extracting(BuildConfiguration::getFrom) .asString() .startsWith("quay.io/jkube/jkube-java"); - } - - @Test - @DisplayName("customize, with standard packaging, has '8080' web port") - void customize_withStandardPackaging_thenImageHasDefaultWebPort() { - // Given - withCustomMainClass(); - SpringBootGenerator springBootGenerator = new SpringBootGenerator(context); - List images = new ArrayList<>(); - - // When - images = springBootGenerator.customize(images, false); + } - // Then - assertThat(images) + @Test + @DisplayName("has '8080' web port") + void imageHasDefaultWebPort() { + // When + final List images = new SpringBootGenerator(context) + .customize(new ArrayList<>(), false); + // Then + assertThat(images) .singleElement() .extracting("buildConfiguration.ports").asList() .contains("8080"); - } - - @Test - @DisplayName("customize, with standard packaging, has Jolokia port") - void customize_withStandardPackaging_hasJolokiaPort() { - // When - final List result = new SpringBootGenerator(context).customize(new ArrayList<>(), true); - // Then - assertThat(result).singleElement() + } + @Test + @DisplayName("has Jolokia port") + void hasJolokiaPort() { + // When + final List images = new SpringBootGenerator(context) + .customize(new ArrayList<>(), false); + // Then + assertThat(images).singleElement() .extracting("buildConfiguration.ports").asList() .contains("8778"); - } + } - @Test - @DisplayName("customize, with standard packaging, has Prometheus port") - void customize_withStandardPackaging_hasPrometheusPort() { - // When - final List result = new SpringBootGenerator(context).customize(new ArrayList<>(), true); - // Then - assertThat(result).singleElement() + @Test + @DisplayName("has Prometheus port") + void hasPrometheusPort() { + // When + final List images = new SpringBootGenerator(context) + .customize(new ArrayList<>(), false); + // Then + assertThat(images).singleElement() .extracting("buildConfiguration.ports").asList() .contains("9779"); - } - - @Test - @DisplayName("customize, in Kubernetes and jar artifact, should create assembly") - void customize_inKubernetesAndJarArtifact_shouldCreateAssembly() throws IOException { - try (MockedConstruction ignore = mockConstruction(FatJarDetector.class, (mock, ctx) -> { - FatJarDetector.Result fatJarDetectorResult = mock(FatJarDetector.Result.class); - when(mock.scan()).thenReturn(fatJarDetectorResult); - when(fatJarDetectorResult.getArchiveFile()).thenReturn(targetDir.toPath().resolve("sample.jar").toFile()); - })) { - // Given - Files.createFile(targetDir.toPath().resolve("sample.jar")); + } + @Test + @DisplayName("has java environment variable for app dir") + void hasJavaJavaAppDirEnvVar() { // When - final List resultImages = new SpringBootGenerator(context).customize(new ArrayList<>(), false); - + final List images = new SpringBootGenerator(context) + .customize(new ArrayList<>(), false); // Then - assertThat(resultImages) - .isNotNull() - .singleElement() - .extracting(ImageConfiguration::getBuild) - .extracting(BuildConfiguration::getAssembly) - .hasFieldOrPropertyWithValue("targetDir", "/deployments") - .hasFieldOrPropertyWithValue("excludeFinalOutputArtifact", true) - .extracting(AssemblyConfiguration::getLayers) - .asList().hasSize(1) - .satisfies(layers -> assertThat(layers).element(0).asInstanceOf(InstanceOfAssertFactories.type(Assembly.class)) - .extracting(Assembly::getFileSets) - .asList().element(2) - .hasFieldOrPropertyWithValue("outputDirectory", new File(".")) - .extracting("includes").asList() - .containsExactly("sample.jar")); + assertThat(images) + .singleElement(InstanceOfAssertFactories.type(ImageConfiguration.class)) + .extracting(ImageConfiguration::getBuild) + .extracting(BuildConfiguration::getEnv) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsEntry("JAVA_APP_DIR", "/deployments"); } - } - @Test - @DisplayName("customize, with standard packaging, has java environment variables") - void customize_withStandardPackaging_thenImageHasJavaMainClassAndJavaAppDirEnvVars() { - // Given - withCustomMainClass(); - SpringBootGenerator springBootGenerator = new SpringBootGenerator(context); - List images = new ArrayList<>(); - - // When - images = springBootGenerator.customize(images, false); + @Test + @DisplayName("creates assembly with Fat Jar") + void createAssembly() { + // When + final List images = new SpringBootGenerator(context) + .customize(new ArrayList<>(), false); + // Then + assertThat(images) + .isNotNull() + .singleElement() + .extracting(ImageConfiguration::getBuild) + .extracting(BuildConfiguration::getAssembly) + .hasFieldOrPropertyWithValue("targetDir", "/deployments") + .hasFieldOrPropertyWithValue("excludeFinalOutputArtifact", true) + .extracting(AssemblyConfiguration::getLayers) + .asList().hasSize(1) + .satisfies(layers -> assertThat(layers).element(0).asInstanceOf(InstanceOfAssertFactories.type(Assembly.class)) + .extracting(Assembly::getFileSets) + .asList().element(2) + .hasFieldOrPropertyWithValue("outputDirectory", new File(".")) + .extracting("includes").asList() + .containsExactly("fat.jar")); + } - // Then - assertThat(images) + @Test + @DisplayName("with custom main class, has java environment for main class") + void withCustomMainClass_hasJavaMainClassEnvVar() { + // Given + properties.put("jkube.generator.spring-boot.mainClass", "org.example.Foo"); + // When + final List images = new SpringBootGenerator(context) + .customize(new ArrayList<>(), false); + // Then + assertThat(images) .singleElement(InstanceOfAssertFactories.type(ImageConfiguration.class)) .extracting(ImageConfiguration::getBuild) .extracting(BuildConfiguration::getEnv) .asInstanceOf(InstanceOfAssertFactories.MAP) .containsEntry("JAVA_MAIN_CLASS", "org.example.Foo") .containsEntry("JAVA_APP_DIR", "/deployments"); - } + } - @Test - @DisplayName("customize, with custom port in application.properties, has overridden web port in image") - void customize_whenApplicationPortOverridden_shouldUseOverriddenWebPort() { - // Given - withCustomMainClass(); - context = context.toBuilder() + @Test + @DisplayName("with custom port in application.properties, has overridden web port in image") + void withApplicationPortOverridden_shouldUseOverriddenWebPort() { + // Given + context = context.toBuilder() .project(context.getProject().toBuilder() - .compileClassPathElement(Objects.requireNonNull(getClass().getResource("/port-override-application-properties")).getPath()) - .build()) + .compileClassPathElement(Objects.requireNonNull(getClass().getResource("/port-override-application-properties")).getPath()) + .build()) .build(); - SpringBootGenerator springBootGenerator = new SpringBootGenerator(context); - List images = new ArrayList<>(); - - // When - images = springBootGenerator.customize(images, false); - - // Then - assertThat(images) + // When + final List images = new SpringBootGenerator(context) + .customize(new ArrayList<>(), false); + // Then + assertThat(images) .singleElement() .extracting("buildConfiguration.ports").asList() .contains("8081"); - } + } - @Test - @DisplayName("customize, when generator mode WATCH, then add Spring Boot Devtools environment variable to image") - void customize_whenGeneratorModeWatch_shouldAddSpringBootDevtoolsSecretEnvVar() { - // Given - withCustomMainClass(); - context = context.toBuilder() + @Test + @DisplayName("with color configuration provided, enables ANSI color output") + void withColorConfiguration_shouldAddAnsiEnabledPropertyToJavaOptions() { + // Given + properties.put("jkube.generator.spring-boot.color", "always"); + // When + final List images = new SpringBootGenerator(context) + .customize(new ArrayList<>(), false); + // Then + assertThat(images) + .singleElement(InstanceOfAssertFactories.type(ImageConfiguration.class)) + .extracting(ImageConfiguration::getBuild) + .extracting(BuildConfiguration::getEnv) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsEntry("JAVA_OPTIONS", "-Dspring.output.ansi.enabled=always"); + } + + @Test + @DisplayName("with generator mode WATCH, then add Spring Boot Devtools environment variable to image") + void withGeneratorModeWatch_shouldAddSpringBootDevtoolsSecretEnvVar() throws IOException { + // Given + final Path applicationProperties = Files.createFile( + Files.createDirectory(targetDir.toPath().resolve("classes")) + .resolve("application.properties")); + context = context.toBuilder() .generatorMode(GeneratorMode.WATCH) .project(context.getProject().toBuilder() - .compileClassPathElement(Objects.requireNonNull(getClass().getResource("/devtools-application-properties")).getPath()) + .compileClassPathElement(Objects.requireNonNull(getClass().getResource("/devtools-application-properties")).getPath()) + .dependency(Dependency.builder() + .groupId("org.springframework.boot") + .artifactId("spring-boot-devtools") + .version(SPRING_VERSION) + .type("jar") + .file(Files.createFile(targetDir.toPath().resolve("spring-boot-devtools.zip")).toFile()) .build()) + .build()) .build(); - SpringBootGenerator springBootGenerator = new SpringBootGenerator(context); - List images = new ArrayList<>(); - - // When - images = springBootGenerator.customize(images, false); - - // Then - assertThat(images) + // When + final List images = new SpringBootGenerator(context) + .customize(new ArrayList<>(), false); + // Then + assertThat(images) .singleElement(InstanceOfAssertFactories.type(ImageConfiguration.class)) .extracting(ImageConfiguration::getBuild) .extracting(BuildConfiguration::getEnv) .asInstanceOf(InstanceOfAssertFactories.MAP) .containsEntry("SPRING_DEVTOOLS_REMOTE_SECRET", "some-secret"); + } } - @Test - @DisplayName("customize, when color configuration provided, enables ANSI color output") - void customize_withColorConfiguration_shouldAddAnsiEnabledPropertyToJavaOptions() { - // Given - properties.put("jkube.generator.spring-boot.color", "always"); - withCustomMainClass(); - List images = new ArrayList<>(); - SpringBootGenerator springBootGenerator = new SpringBootGenerator(context); + @Nested + @DisplayName("With layered jar") + class LayeredJar { - // When - images = springBootGenerator.customize(images, false); - - // Then - assertThat(images) - .singleElement(InstanceOfAssertFactories.type(ImageConfiguration.class)) + @Test + @DisplayName("should create assembly layers matching layered jar layers") + void shouldCreateAssemblyLayers() throws IOException { + // Given + Files.copy( + Objects.requireNonNull(SpringBootGeneratorIntegrationTest.class.getResourceAsStream("/generator-integration-test/layered-jar.jar")), + targetDir.toPath().resolve("layered.jar") + ); + // When + final List images = new SpringBootGenerator(context).customize(new ArrayList<>(), false); + // Then + assertThat(images) + .isNotNull() + .singleElement() .extracting(ImageConfiguration::getBuild) - .extracting(BuildConfiguration::getEnv) - .asInstanceOf(InstanceOfAssertFactories.MAP) - .containsEntry("JAVA_OPTIONS", "-Dspring.output.ansi.enabled=always"); + .satisfies(b -> assertThat(b.getEnv()) + .containsEntry("JAVA_MAIN_CLASS", "org.springframework.boot.loader.JarLauncher")) + .extracting(BuildConfiguration::getAssembly) + .hasFieldOrPropertyWithValue("targetDir", "/deployments") + .hasFieldOrPropertyWithValue("excludeFinalOutputArtifact", true) + .extracting(AssemblyConfiguration::getLayers) + .asList() + .hasSize(5) + .contains( + Assembly.builder() + .id("jkube-includes") + .fileSet(AssemblyFileSet.builder() + .directory(new File("src/main/jkube-includes/bin")) + .outputDirectory(new File("bin")) + .fileMode("0755") + .build()) + .fileSet(AssemblyFileSet.builder() + .directory(new File("src/main/jkube-includes")) + .outputDirectory(new File(".")) + .fileMode("0644") + .build()) + .build(), + Assembly.builder() + .id("dependencies") + .fileSet(AssemblyFileSet.builder() + .outputDirectory(new File(".")) + .directory(new File("target/dependencies")) + .exclude("*") + .fileMode("0640") + .build()) + .build(), + Assembly.builder() + .id("spring-boot-loader") + .fileSet(AssemblyFileSet.builder() + .outputDirectory(new File(".")) + .directory(new File("target/spring-boot-loader")) + .exclude("*") + .fileMode("0640") + .build()) + .build(), + Assembly.builder() + .id("snapshot-dependencies") + .fileSet(AssemblyFileSet.builder() + .outputDirectory(new File(".")) + .directory(new File("target/snapshot-dependencies")) + .exclude("*") + .fileMode("0640") + .build()) + .build(), + Assembly.builder() + .id("application") + .fileSet(AssemblyFileSet.builder() + .outputDirectory(new File(".")) + .directory(new File("target/application")) + .exclude("*") + .fileMode("0640") + .build()) + .build() + ); + } } - private void withCustomMainClass() { - properties.put("jkube.generator.spring-boot.mainClass", "org.example.Foo"); - } } diff --git a/jkube-kit/jkube-kit-spring-boot/src/test/resources/generator-integration-test/layered-jar.jar b/jkube-kit/jkube-kit-spring-boot/src/test/resources/generator-integration-test/layered-jar.jar new file mode 100644 index 0000000000..17025649b1 Binary files /dev/null and b/jkube-kit/jkube-kit-spring-boot/src/test/resources/generator-integration-test/layered-jar.jar differ