diff --git a/spring-boot-tools/spring-boot-gradle-plugin/src/main/groovy/org/springframework/boot/gradle/SpringBootPluginExtension.groovy b/spring-boot-tools/spring-boot-gradle-plugin/src/main/groovy/org/springframework/boot/gradle/SpringBootPluginExtension.groovy index 86abbc3a745e..a81a27bfdc9e 100644 --- a/spring-boot-tools/spring-boot-gradle-plugin/src/main/groovy/org/springframework/boot/gradle/SpringBootPluginExtension.groovy +++ b/spring-boot-tools/spring-boot-gradle-plugin/src/main/groovy/org/springframework/boot/gradle/SpringBootPluginExtension.groovy @@ -16,6 +16,9 @@ package org.springframework.boot.gradle +import java.io.File; +import java.util.Map; + import org.springframework.boot.loader.tools.Layout import org.springframework.boot.loader.tools.Layouts @@ -130,4 +133,21 @@ public class SpringBootPluginExtension { */ boolean applyExcludeRules = true; + /** + * If a fully executable jar (for *nix machines) should be generated by prepending a + * launch script to the jar. + */ + boolean executable = true; + + /** + * The embedded launch script to prepend to the front of the jar if it is fully + * executable. If not specified the 'Spring Boot' default script will be used. + */ + File embeddedLaunchScript; + + /** + * Properties that should be expanded in the embedded launch script. + */ + Map embeddedLaunchScriptProperties; + } diff --git a/spring-boot-tools/spring-boot-gradle-plugin/src/main/groovy/org/springframework/boot/gradle/repackage/RepackageTask.java b/spring-boot-tools/spring-boot-gradle-plugin/src/main/groovy/org/springframework/boot/gradle/repackage/RepackageTask.java index 932017ba0d98..97d7fdea71be 100644 --- a/spring-boot-tools/spring-boot-gradle-plugin/src/main/groovy/org/springframework/boot/gradle/repackage/RepackageTask.java +++ b/spring-boot-tools/spring-boot-gradle-plugin/src/main/groovy/org/springframework/boot/gradle/repackage/RepackageTask.java @@ -28,6 +28,8 @@ import org.gradle.api.tasks.TaskAction; import org.gradle.api.tasks.bundling.Jar; import org.springframework.boot.gradle.SpringBootPluginExtension; +import org.springframework.boot.loader.tools.DefaultLaunchScript; +import org.springframework.boot.loader.tools.LaunchScript; import org.springframework.boot.loader.tools.Repackager; import org.springframework.util.FileCopyUtils; @@ -80,6 +82,10 @@ public void setClassifier(String classifier) { this.classifier = classifier; } + void setOutputFile(File file) { + this.outputFile = file; + } + @TaskAction public void repackage() { Project project = getProject(); @@ -170,7 +176,8 @@ private void repackage(File file) { } repackager.setBackupSource(this.extension.isBackupSource()); try { - repackager.repackage(file, this.libraries); + LaunchScript launchScript = getLaunchScript(); + repackager.repackage(file, this.libraries, launchScript); } catch (IOException ex) { throw new IllegalStateException(ex.getMessage(), ex); @@ -201,6 +208,15 @@ else if (getProject().getTasks().getByName("run").hasProperty("main")) { getLogger().info("Setting mainClass: " + mainClass); repackager.setMainClass(mainClass); } + + private LaunchScript getLaunchScript() throws IOException { + if (this.extension.isExecutable()) { + return new DefaultLaunchScript(this.extension.getEmbeddedLaunchScript(), + this.extension.getEmbeddedLaunchScriptProperties()); + } + return null; + } + } /** @@ -228,10 +244,7 @@ protected String findMainMethod(java.util.jar.JarFile source) throws IOException } } } - } - void setOutputFile(File file) { - this.outputFile = file; } } diff --git a/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/DefaultLaunchScript.java b/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/DefaultLaunchScript.java new file mode 100644 index 000000000000..0ba4f7a0aa89 --- /dev/null +++ b/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/DefaultLaunchScript.java @@ -0,0 +1,111 @@ +/* + * Copyright 2012-2015 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 + * + * 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 org.springframework.boot.loader.tools; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Default implementation of {@link LaunchScript}. Provides the default Spring Boot launch + * script or can load a specific script File. Also support mustache style template + * expansion of the form {{name:default}}. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class DefaultLaunchScript implements LaunchScript { + + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + private static final int BUFFER_SIZE = 4096; + + private static final Pattern PLACEHOLDER_PATTERN = Pattern + .compile("\\{\\{(\\w+)(:.*?)?\\}\\}"); + + private final String content; + + /** + * Create a new {@link DefaultLaunchScript} instance. + * @param file the source script file or {@code null} to use the default + * @param properties an optional set of script properties used for variable expansion + * @throws IOException if the script cannot be loaded + */ + public DefaultLaunchScript(File file, Map properties) throws IOException { + String content = loadContent(file); + this.content = expandPlaceholders(content, properties); + } + + private String loadContent(File file) throws IOException { + if (file == null) { + return loadContent(getClass().getResourceAsStream("launch.script")); + } + return loadContent(new FileInputStream(file)); + } + + private String loadContent(InputStream inputStream) throws IOException { + try { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + copy(inputStream, outputStream); + return new String(outputStream.toByteArray(), UTF_8); + } + finally { + inputStream.close(); + } + } + + private void copy(InputStream inputStream, OutputStream outputStream) + throws IOException { + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead = -1; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + outputStream.flush(); + } + + private String expandPlaceholders(String content, Map properties) { + StringBuffer expanded = new StringBuffer(); + Matcher matcher = PLACEHOLDER_PATTERN.matcher(content); + while (matcher.find()) { + String name = matcher.group(1); + String value = matcher.group(2); + if (properties != null && properties.containsKey(name)) { + value = (String) properties.get(name); + } + else { + value = (value == null ? matcher.group(0) : value.substring(1)); + } + matcher.appendReplacement(expanded, value); + } + matcher.appendTail(expanded); + return expanded.toString(); + } + + @Override + public byte[] toByteArray() { + return this.content.getBytes(UTF_8); + } + +} diff --git a/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java b/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java index ef35fc473763..f2c365ac6e1d 100644 --- a/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java +++ b/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java @@ -27,6 +27,9 @@ import java.io.InputStream; import java.io.OutputStream; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; import java.util.Arrays; import java.util.Enumeration; import java.util.HashSet; @@ -63,7 +66,37 @@ public class JarWriter { * @throws FileNotFoundException */ public JarWriter(File file) throws FileNotFoundException, IOException { - this.jarOutput = new JarOutputStream(new FileOutputStream(file)); + this(file, null); + } + + /** + * Create a new {@link JarWriter} instance. + * @param file the file to write + * @param launchScript an optional launch script to prepend to the front of the jar + * @throws IOException + * @throws FileNotFoundException + */ + public JarWriter(File file, LaunchScript launchScript) throws FileNotFoundException, + IOException { + FileOutputStream fileOutputStream = new FileOutputStream(file); + if (launchScript != null) { + fileOutputStream.write(launchScript.toByteArray()); + setExecutableFilePermission(file); + } + this.jarOutput = new JarOutputStream(fileOutputStream); + } + + private void setExecutableFilePermission(File file) { + try { + Path path = file.toPath(); + Set permissions = new HashSet( + Files.getPosixFilePermissions(path)); + permissions.add(PosixFilePermission.OWNER_EXECUTE); + Files.setPosixFilePermissions(path, permissions); + } + catch (Throwable ex) { + // Ignore and continue creating the jar + } } /** diff --git a/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/LaunchScript.java b/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/LaunchScript.java new file mode 100644 index 000000000000..f9b3bfada4c9 --- /dev/null +++ b/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/LaunchScript.java @@ -0,0 +1,33 @@ +/* + * Copyright 2012-2015 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 + * + * 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 org.springframework.boot.loader.tools; + +/** + * A script that can be prepended to the front of a JAR file to make it executable. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public interface LaunchScript { + + /** + * The the content of the launch script as a byte array. + * @return the script bytes + */ + byte[] toByteArray(); + +} diff --git a/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java b/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java index 97da6ffc71b8..b79c9b84b374 100644 --- a/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java +++ b/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java @@ -104,17 +104,29 @@ public void repackage(Libraries libraries) throws IOException { * @throws IOException */ public void repackage(File destination, Libraries libraries) throws IOException { + repackage(destination, libraries, null); + } + + /** + * Repackage to the given destination so that it can be launched using ' + * {@literal java -jar}' + * @param destination the destination file (may be the same as the source) + * @param libraries the libraries required to run the archive + * @param launchScript an optional launch script prepended to the front of the jar + * @throws IOException + * @since 1.3.0 + */ + public void repackage(File destination, Libraries libraries, LaunchScript launchScript) + throws IOException { if (destination == null || destination.isDirectory()) { throw new IllegalArgumentException("Invalid destination"); } if (libraries == null) { throw new IllegalArgumentException("Libraries must not be null"); } - if (alreadyRepackaged()) { return; } - destination = destination.getAbsoluteFile(); File workingSource = this.source; if (this.source.equals(destination)) { @@ -127,7 +139,7 @@ public void repackage(File destination, Libraries libraries) throws IOException try { JarFile jarFileSource = new JarFile(workingSource); try { - repackage(jarFileSource, destination, libraries); + repackage(jarFileSource, destination, libraries, launchScript); } finally { jarFileSource.close(); @@ -152,9 +164,9 @@ private boolean alreadyRepackaged() throws IOException { } } - private void repackage(JarFile sourceJar, File destination, Libraries libraries) - throws IOException { - final JarWriter writer = new JarWriter(destination); + private void repackage(JarFile sourceJar, File destination, Libraries libraries, + LaunchScript launchScript) throws IOException { + final JarWriter writer = new JarWriter(destination, launchScript); try { final Set seen = new HashSet(); writer.writeManifest(buildManifest(sourceJar)); diff --git a/spring-boot-tools/spring-boot-loader-tools/src/main/resources/org/springframework/boot/loader/tools/launch.script b/spring-boot-tools/spring-boot-loader-tools/src/main/resources/org/springframework/boot/loader/tools/launch.script new file mode 100644 index 000000000000..6da00583dda7 --- /dev/null +++ b/spring-boot-tools/spring-boot-loader-tools/src/main/resources/org/springframework/boot/loader/tools/launch.script @@ -0,0 +1,164 @@ +#!/bin/bash +# +# . ____ _ __ _ _ +# /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \ +# ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ +# \\/ ___)| |_)| | | | | || (_| | ) ) ) ) +# ' |____| .__|_| |_|_| |_\__, | / / / / +# =========|_|==============|___/=/_/_/_/ +# :: Spring Boot Startup Script :: +# + +WORKING_DIR="$(pwd)" +PID_FOLDER="/var/run" +USER_PID_FOLDER="/tmp" +LOG_FOLDER="/var/log" +USER_LOG_FOLDER="/tmp" + +# Setup defaults +[[ -z "$mode" ]] && mode="{{mode:auto}}" # modes are "auto", "service" or "run" + +# ANSI Colors +echoRed() { echo $'\e[0;31m'$1$'\e[0m'; } +echoGreen() { echo $'\e[0;32m'$1$'\e[0m'; } +echoYellow() { echo $'\e[0;33m'$1$'\e[0m'; } + +# Follow symlinks to find the real jar and detect init.d script +cd $(dirname "$0") +[[ -z "$jarfile" ]] && jarfile=$(pwd)/$(basename "$0") +while [[ -L "$jarfile" ]]; do + [[ "$jarfile" =~ "init.d" ]] && init_script=$(basename "$jarfile") + jarfile=$(readlink "$jarfile") + cd $(dirname "$jarfile") + jarfile=$(pwd)/$(basename "$jarfile") +done +cd "$WORKING_DIR" + +# Determine the script mode +action="run" +if [[ "$mode" == "auto" && -n "$init_script" ]] || [[ "$mode" == "service" ]]; then + action="$1" + shift +fi + +# Create an identity for log/pid files +if [[ -n "$init_script" ]]; then + identity="${init_script}" +else + jar_folder=$(dirname "$jarfile") + identity=$(basename "${jarfile%.*}")_${jar_folder//\//} +fi + +# Build the pid and log filenames +if [[ -n "$init_script" ]]; then + pid_file="$PID_FOLDER/${identity}/${identity}.pid" + log_file="$LOG_FOLDER/${identity}.log" +else + pid_file="$USER_PID_FOLDER/${identity}.pid" + log_file="$USER_LOG_FOLDER/${identity}.log" +fi + +# Determine the user to run as +[[ $(id -u) == "0" ]] && run_user=$(ls -ld "$jarfile" | awk '{print $3}') + +# Find Java +if type -p java 2>&1> /dev/null; then + javaexe=java +elif [[ -n "$JAVA_HOME" ]] && [[ -x "$JAVA_HOME/bin/java" ]]; then + javaexe="$JAVA_HOME/bin/java" +elif [[ -x "/usr/bin/java" ]]; then + javaexe="/usr/bin/java" +else + echo "Unable to find Java" + exit 1 +fi + +# Build actual command to execute +command="$javaexe -jar -Dsun.misc.URLClassPath.disableJarChecking=true $jarfile $@" + +# Utility functions +checkPermissions() { + touch "$pid_file" &> /dev/null || { echoRed "Operation not permitted (cannot access pid file)"; exit 1; } + touch "$log_file" &> /dev/null || { echoRed "Operation not permitted (cannot access log file)"; exit 1; } +} + +isRunning() { + ps -p $1 &> /dev/null +} + +# Action functions +start() { + if [[ -f "$pid_file" ]]; then + pid=$(cat "$pid_file") + isRunning $pid && { echoYellow "Already running [$pid]"; exit 0; } + fi + pushd $(dirname "$jarfile") > /dev/null + if [[ -n "$run_user" ]]; then + mkdir "$PID_FOLDER/${identity}" &> /dev/null + checkPermissions + chown "$run_user" "$PID_FOLDER/${identity}" + chown "$run_user" "$pid_file" + chown "$run_user" "$log_file" + su -c "$command &> \"$log_file\" & echo \$!" $run_user > "$pid_file" + pid=$(cat "$pid_file") + else + checkPermissions + $command &> "$log_file" & + pid=$! + disown $pid + echo "$pid" > "$pid_file" + fi + [[ -z $pid ]] && { echoRed "Failed to start"; exit 1; } + echoGreen "Started [$pid]" +} + +stop() { + [[ -f $pid_file ]] || { echoRed "Not running (pidfile not found)"; exit 1; } + pid=$(cat "$pid_file") + isRunning $pid || { echoRed "Not running (process ${pid} not found)"; exit 1; } + kill -HUP $pid &> /dev/null || { echoRed "Unable to kill process ${pid}"; exit 1; } + for i in $(seq 1 20); do + isRunning ${pid} || { echoGreen "Stopped [$pid]"; rm -f $pid_file; exit 0; } + sleep 1 + done + echoRed "Unable to kill process ${pid}"; + exit 3; +} + +restart() { + stop + start +} + +status() { + [[ -f $pid_file ]] || { echoRed "Not running"; exit 1; } + pid=$(cat "$pid_file") + isRunning $pid || { echoRed "Not running (process ${pid} not found)"; exit 1; } + echoGreen "Running [$pid]" + exit 0 +} + +run() { + pushd $(dirname "$jarfile") > /dev/null + exec $command + popd +} + +# Call the appropriate action function +case "$action" in +start) + start "$@";; +stop) + stop "$@";; +restart) + restart "$@";; +status) + status "$@";; +run) + run "$@";; +*) + echo "Usage: $0 {start|stop|restart|status|run}"; exit 1; +esac + +exit 0 + diff --git a/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/DefaultLaunchScriptTests.java b/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/DefaultLaunchScriptTests.java new file mode 100644 index 000000000000..6c190b1feb07 --- /dev/null +++ b/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/DefaultLaunchScriptTests.java @@ -0,0 +1,111 @@ +/* + * Copyright 2012-2015 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 + * + * 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 org.springframework.boot.loader.tools; + +import java.io.File; +import java.util.Properties; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.springframework.util.FileCopyUtils; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link DefaultLaunchScript}. + * + * @author Phillip Webb + */ +public class DefaultLaunchScriptTests { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void loadsDefaultScript() throws Exception { + DefaultLaunchScript script = new DefaultLaunchScript(null, null); + String content = new String(script.toByteArray()); + assertThat(content, containsString("Spring Boot Startup Script")); + assertThat(content, containsString("mode=\"auto\"")); + } + + @Test + public void loadFromFile() throws Exception { + File file = this.temporaryFolder.newFile(); + FileCopyUtils.copy("ABC".getBytes(), file); + DefaultLaunchScript script = new DefaultLaunchScript(file, null); + String content = new String(script.toByteArray()); + assertThat(content, equalTo("ABC")); + } + + @Test + public void expandVariables() throws Exception { + File file = this.temporaryFolder.newFile(); + FileCopyUtils.copy("h{{a}}ll{{b}}".getBytes(), file); + Properties properties = new Properties(); + properties.put("a", "e"); + properties.put("b", "o"); + DefaultLaunchScript script = new DefaultLaunchScript(file, properties); + String content = new String(script.toByteArray()); + assertThat(content, equalTo("hello")); + } + + @Test + public void expandVariablesMultiLine() throws Exception { + File file = this.temporaryFolder.newFile(); + FileCopyUtils.copy("h{{a}}l\nl{{b}}".getBytes(), file); + Properties properties = new Properties(); + properties.put("a", "e"); + properties.put("b", "o"); + DefaultLaunchScript script = new DefaultLaunchScript(file, properties); + String content = new String(script.toByteArray()); + assertThat(content, equalTo("hel\nlo")); + } + + @Test + public void expandVariablesWithDefaults() throws Exception { + File file = this.temporaryFolder.newFile(); + FileCopyUtils.copy("h{{a:e}}ll{{b:o}}".getBytes(), file); + DefaultLaunchScript script = new DefaultLaunchScript(file, null); + String content = new String(script.toByteArray()); + assertThat(content, equalTo("hello")); + } + + @Test + public void expandVariablesWithDefaultsOverride() throws Exception { + File file = this.temporaryFolder.newFile(); + FileCopyUtils.copy("h{{a:e}}ll{{b:o}}".getBytes(), file); + Properties properties = new Properties(); + properties.put("a", "a"); + DefaultLaunchScript script = new DefaultLaunchScript(file, properties); + String content = new String(script.toByteArray()); + assertThat(content, equalTo("hallo")); + } + + @Test + public void expandVariablesMissingAreUnchanged() throws Exception { + File file = this.temporaryFolder.newFile(); + FileCopyUtils.copy("h{{a}}ll{{b}}".getBytes(), file); + DefaultLaunchScript script = new DefaultLaunchScript(file, null); + String content = new String(script.toByteArray()); + assertThat(content, equalTo("h{{a}}ll{{b}}")); + } + +} diff --git a/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java b/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java index a8a92de05ea0..0afdac652f2d 100644 --- a/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java +++ b/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java @@ -18,6 +18,8 @@ import java.io.File; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.attribute.PosixFilePermission; import java.util.jar.Attributes; import java.util.jar.JarEntry; import java.util.jar.JarFile; @@ -34,6 +36,7 @@ import org.springframework.util.FileCopyUtils; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.startsWith; import static org.junit.Assert.assertThat; import static org.mockito.BDDMockito.given; @@ -141,7 +144,6 @@ public void jarIsOnlyRepackagedOnce() throws Exception { Repackager repackager = new Repackager(file); repackager.repackage(NO_LIBRARIES); repackager.repackage(NO_LIBRARIES); - Manifest actualManifest = getManifest(file); assertThat(actualManifest.getMainAttributes().getValue("Main-Class"), equalTo("org.springframework.boot.loader.JarLauncher")); @@ -230,7 +232,6 @@ public void differentDestination() throws Exception { equalTo(false)); assertThat(hasLauncherClasses(source), equalTo(false)); assertThat(hasLauncherClasses(dest), equalTo(true)); - } @Test @@ -380,7 +381,6 @@ public void doWithLibraries(LibraryCallback callback) throws IOException { callback.library(new Library(nestedFile, LibraryScope.COMPILE)); } }); - JarFile jarFile = new JarFile(file); try { assertThat(jarFile.getEntry("lib/" + nestedFile.getName()).getMethod(), @@ -393,6 +393,22 @@ public void doWithLibraries(LibraryCallback callback) throws IOException { } } + @Test + public void addLauncherScript() throws Exception { + this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); + File source = this.testJarFile.getFile(); + File dest = this.temporaryFolder.newFile("dest.jar"); + Repackager repackager = new Repackager(source); + LaunchScript script = new MockLauncherScript("ABC"); + repackager.repackage(dest, NO_LIBRARIES, script); + byte[] bytes = FileCopyUtils.copyToByteArray(dest); + assertThat(Files.getPosixFilePermissions(dest.toPath()), + hasItem(PosixFilePermission.OWNER_EXECUTE)); + assertThat(new String(bytes), startsWith("ABC")); + assertThat(hasLauncherClasses(source), equalTo(false)); + assertThat(hasLauncherClasses(dest), equalTo(true)); + } + private boolean hasLauncherClasses(File file) throws IOException { return hasEntry(file, "org/springframework/boot/") && hasEntry(file, "org/springframework/boot/loader/JarLauncher.class"); @@ -422,4 +438,19 @@ private Manifest getManifest(File file) throws IOException { } } + private static class MockLauncherScript implements LaunchScript { + + private final byte[] bytes; + + public MockLauncherScript(String script) { + this.bytes = script.getBytes(); + } + + @Override + public byte[] toByteArray() { + return this.bytes; + } + + } + } diff --git a/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-custom-launcher/pom.xml b/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-custom-launcher/pom.xml new file mode 100644 index 000000000000..1facd708d44b --- /dev/null +++ b/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-custom-launcher/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + org.springframework.boot.maven.it + jar + 0.0.1.BUILD-SNAPSHOT + + UTF-8 + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + + repackage + + + ${basedir}/src/launcher/custom.script + + world + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 2.4 + + + + some.random.Main + + + Foo + + + + + + + + + org.springframework + spring-context + @spring.version@ + + + javax.servlet + javax.servlet-api + @servlet-api.version@ + provided + + + diff --git a/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-custom-launcher/src/launcher/custom.script b/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-custom-launcher/src/launcher/custom.script new file mode 100644 index 000000000000..f8663275c8a1 --- /dev/null +++ b/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-custom-launcher/src/launcher/custom.script @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Hello {{name}}" diff --git a/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-custom-launcher/src/main/java/org/test/SampleApplication.java b/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-custom-launcher/src/main/java/org/test/SampleApplication.java new file mode 100644 index 000000000000..403cd968451b --- /dev/null +++ b/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-custom-launcher/src/main/java/org/test/SampleApplication.java @@ -0,0 +1,24 @@ +/* + * Copyright 2012-2014 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 + * + * 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 org.test; + +public class SampleApplication { + + public static void main(String[] args) { + } + +} diff --git a/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-custom-launcher/verify.groovy b/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-custom-launcher/verify.groovy new file mode 100644 index 000000000000..8c4a68e53f9b --- /dev/null +++ b/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-custom-launcher/verify.groovy @@ -0,0 +1,7 @@ +import java.io.*; +import org.springframework.boot.maven.*; + +Verify.verifyJar( + new File( basedir, "target/jar-0.0.1.BUILD-SNAPSHOT.jar" ), "some.random.Main", "Hello world" +); + diff --git a/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-non-executable/pom.xml b/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-non-executable/pom.xml new file mode 100644 index 000000000000..a65dd9ae0d94 --- /dev/null +++ b/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-non-executable/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + org.springframework.boot.maven.it + jar + 0.0.1.BUILD-SNAPSHOT + + UTF-8 + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + + repackage + + + false + + + + + + org.apache.maven.plugins + maven-jar-plugin + 2.4 + + + + some.random.Main + + + Foo + + + + + + + + + org.springframework + spring-context + @spring.version@ + + + javax.servlet + javax.servlet-api + @servlet-api.version@ + provided + + + diff --git a/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-non-executable/src/main/java/org/test/SampleApplication.java b/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-non-executable/src/main/java/org/test/SampleApplication.java new file mode 100644 index 000000000000..403cd968451b --- /dev/null +++ b/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-non-executable/src/main/java/org/test/SampleApplication.java @@ -0,0 +1,24 @@ +/* + * Copyright 2012-2014 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 + * + * 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 org.test; + +public class SampleApplication { + + public static void main(String[] args) { + } + +} diff --git a/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-non-executable/verify.groovy b/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-non-executable/verify.groovy new file mode 100644 index 000000000000..6ce822cd5670 --- /dev/null +++ b/spring-boot-tools/spring-boot-maven-plugin/src/it/jar-non-executable/verify.groovy @@ -0,0 +1,7 @@ +import java.io.*; +import org.springframework.boot.maven.*; + +Verify.verifyJar( + new File( basedir, "target/jar-0.0.1.BUILD-SNAPSHOT.jar" ), "some.random.Main", false +); + diff --git a/spring-boot-tools/spring-boot-maven-plugin/src/it/jar/verify.groovy b/spring-boot-tools/spring-boot-maven-plugin/src/it/jar/verify.groovy index 07b375b51fca..8b5af07aa876 100644 --- a/spring-boot-tools/spring-boot-maven-plugin/src/it/jar/verify.groovy +++ b/spring-boot-tools/spring-boot-maven-plugin/src/it/jar/verify.groovy @@ -2,6 +2,6 @@ import java.io.*; import org.springframework.boot.maven.*; Verify.verifyJar( - new File( basedir, "target/jar-0.0.1.BUILD-SNAPSHOT.jar" ), "some.random.Main" + new File( basedir, "target/jar-0.0.1.BUILD-SNAPSHOT.jar" ), "some.random.Main", "Spring Boot Startup Script" ); diff --git a/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/RepackageMojo.java b/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/RepackageMojo.java index fee5481c326a..49e83edc2fc9 100644 --- a/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/RepackageMojo.java +++ b/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/RepackageMojo.java @@ -19,6 +19,7 @@ import java.io.File; import java.io.IOException; import java.util.List; +import java.util.Properties; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.jar.JarFile; @@ -34,6 +35,8 @@ import org.apache.maven.plugins.annotations.ResolutionScope; import org.apache.maven.project.MavenProject; import org.apache.maven.project.MavenProjectHelper; +import org.springframework.boot.loader.tools.DefaultLaunchScript; +import org.springframework.boot.loader.tools.LaunchScript; import org.springframework.boot.loader.tools.Layout; import org.springframework.boot.loader.tools.Layouts; import org.springframework.boot.loader.tools.Libraries; @@ -124,6 +127,29 @@ public class RepackageMojo extends AbstractDependencyFilterMojo { @Parameter private List requiresUnpack; + /** + * Make a fully executable jar for *nix machines by prepending a launch script to the + * jar. + * @since 1.3 + */ + @Parameter(defaultValue = "true") + private boolean executable; + + /** + * The embedded launch script to prepend to the front of the jar if it is fully + * executable. If not specified the 'Spring Boot' default script will be used. + * @since 1.3 + */ + @Parameter + private File embeddedLaunchScript; + + /** + * Properties that should be expanded in the embedded launch script. + * @since 1.3 + */ + @Parameter + private Properties embeddedLaunchScriptProperties; + @Override public void execute() throws MojoExecutionException, MojoFailureException { if (this.project.getPackaging().equals("pom")) { @@ -167,7 +193,8 @@ protected String findMainMethod(JarFile source) throws IOException { Libraries libraries = new ArtifactsLibraries(artifacts, this.requiresUnpack, getLog()); try { - repackager.repackage(target, libraries); + LaunchScript launchScript = getLaunchScript(); + repackager.repackage(target, libraries, launchScript); } catch (IOException ex) { throw new MojoExecutionException(ex.getMessage(), ex); @@ -190,6 +217,14 @@ private File getTargetFile() { + this.project.getPackaging()); } + private LaunchScript getLaunchScript() throws IOException { + if (this.executable) { + return new DefaultLaunchScript(this.embeddedLaunchScript, + this.embeddedLaunchScriptProperties); + } + return null; + } + public static enum LayoutType { /** diff --git a/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/Verify.java b/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/Verify.java index f55377de2c31..2eeab97bfbaa 100644 --- a/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/Verify.java +++ b/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/Verify.java @@ -26,8 +26,12 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipFile; +import org.springframework.util.FileCopyUtils; + +import static org.hamcrest.Matchers.containsString; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; /** @@ -44,8 +48,14 @@ public static void verifyJar(File file) throws Exception { new JarArchiveVerification(file, SAMPLE_APP).verify(); } - public static void verifyJar(File file, String main) throws Exception { - new JarArchiveVerification(file, main).verify(); + public static void verifyJar(File file, String main, String... scriptContents) + throws Exception { + verifyJar(file, main, true, scriptContents); + } + + public static void verifyJar(File file, String main, boolean executable, + String... scriptContents) throws Exception { + new JarArchiveVerification(file, main).verify(executable, scriptContents); } public static void verifyWar(File file) throws Exception { @@ -149,9 +159,30 @@ public AbstractArchiveVerification(File file) { } public void verify() throws Exception { + verify(true); + } + + public void verify(boolean executable, String... scriptContents) throws Exception { assertTrue("Archive missing", this.file.exists()); assertTrue("Archive not a file", this.file.isFile()); + if (scriptContents.length > 0 && executable) { + String contents = new String(FileCopyUtils.copyToByteArray(this.file)); + contents = contents.substring(0, contents.indexOf(new String(new byte[] { + 0x50, 0x4b, 0x03, 0x04 }))); + for (String content : scriptContents) { + assertThat(contents, containsString(content)); + } + } + + if (!executable) { + String contents = new String(FileCopyUtils.copyToByteArray(this.file)); + assertTrue( + "Is executable", + contents.startsWith(new String(new byte[] { 0x50, 0x4b, 0x03, + 0x04 }))); + } + ZipFile zipFile = new ZipFile(this.file); try { ArchiveVerifier verifier = new ArchiveVerifier(zipFile);