Skip to content

Commit 7934818

Browse files
committed
Support embedded jar initialization scripts
Update the Maven and Gradle plugin to generate fully executable jar files on Unix like machines. A launcher bash script is added to the front of the jar file which handles execution. The default execution script will either launch the application or handle init.d service operations (start/stop/restart) depending on if the application is executed directly, or via a symlink to init.d. See gh-1117
1 parent ffc5d56 commit 7934818

File tree

19 files changed

+796
-18
lines changed

19 files changed

+796
-18
lines changed

spring-boot-tools/spring-boot-gradle-plugin/src/main/groovy/org/springframework/boot/gradle/SpringBootPluginExtension.groovy

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616

1717
package org.springframework.boot.gradle
1818

19+
import java.io.File;
20+
import java.util.Map;
21+
1922
import org.springframework.boot.loader.tools.Layout
2023
import org.springframework.boot.loader.tools.Layouts
2124

@@ -130,4 +133,21 @@ public class SpringBootPluginExtension {
130133
*/
131134
boolean applyExcludeRules = true;
132135

136+
/**
137+
* If a fully executable jar (for *nix machines) should be generated by prepending a
138+
* launch script to the jar.
139+
*/
140+
boolean executable = true;
141+
142+
/**
143+
* The embedded launch script to prepend to the front of the jar if it is fully
144+
* executable. If not specified the 'Spring Boot' default script will be used.
145+
*/
146+
File embeddedLaunchScript;
147+
148+
/**
149+
* Properties that should be expanded in the embedded launch script.
150+
*/
151+
Map<String,String> embeddedLaunchScriptProperties;
152+
133153
}

spring-boot-tools/spring-boot-gradle-plugin/src/main/groovy/org/springframework/boot/gradle/repackage/RepackageTask.java

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
import org.gradle.api.tasks.TaskAction;
2929
import org.gradle.api.tasks.bundling.Jar;
3030
import org.springframework.boot.gradle.SpringBootPluginExtension;
31+
import org.springframework.boot.loader.tools.DefaultLaunchScript;
32+
import org.springframework.boot.loader.tools.LaunchScript;
3133
import org.springframework.boot.loader.tools.Repackager;
3234
import org.springframework.util.FileCopyUtils;
3335

@@ -80,6 +82,10 @@ public void setClassifier(String classifier) {
8082
this.classifier = classifier;
8183
}
8284

85+
void setOutputFile(File file) {
86+
this.outputFile = file;
87+
}
88+
8389
@TaskAction
8490
public void repackage() {
8591
Project project = getProject();
@@ -170,7 +176,8 @@ private void repackage(File file) {
170176
}
171177
repackager.setBackupSource(this.extension.isBackupSource());
172178
try {
173-
repackager.repackage(file, this.libraries);
179+
LaunchScript launchScript = getLaunchScript();
180+
repackager.repackage(file, this.libraries, launchScript);
174181
}
175182
catch (IOException ex) {
176183
throw new IllegalStateException(ex.getMessage(), ex);
@@ -201,6 +208,15 @@ else if (getProject().getTasks().getByName("run").hasProperty("main")) {
201208
getLogger().info("Setting mainClass: " + mainClass);
202209
repackager.setMainClass(mainClass);
203210
}
211+
212+
private LaunchScript getLaunchScript() throws IOException {
213+
if (this.extension.isExecutable()) {
214+
return new DefaultLaunchScript(this.extension.getEmbeddedLaunchScript(),
215+
this.extension.getEmbeddedLaunchScriptProperties());
216+
}
217+
return null;
218+
}
219+
204220
}
205221

206222
/**
@@ -228,10 +244,7 @@ protected String findMainMethod(java.util.jar.JarFile source) throws IOException
228244
}
229245
}
230246
}
231-
}
232247

233-
void setOutputFile(File file) {
234-
this.outputFile = file;
235248
}
236249

237250
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* Copyright 2012-2015 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.loader.tools;
18+
19+
import java.io.ByteArrayOutputStream;
20+
import java.io.File;
21+
import java.io.FileInputStream;
22+
import java.io.IOException;
23+
import java.io.InputStream;
24+
import java.io.OutputStream;
25+
import java.nio.charset.Charset;
26+
import java.util.Map;
27+
import java.util.regex.Matcher;
28+
import java.util.regex.Pattern;
29+
30+
/**
31+
* Default implementation of {@link LaunchScript}. Provides the default Spring Boot launch
32+
* script or can load a specific script File. Also support mustache style template
33+
* expansion of the form <code>{{name:default}}</code>.
34+
*
35+
* @author Phillip Webb
36+
* @since 1.3.0
37+
*/
38+
public class DefaultLaunchScript implements LaunchScript {
39+
40+
private static final Charset UTF_8 = Charset.forName("UTF-8");
41+
42+
private static final int BUFFER_SIZE = 4096;
43+
44+
private static final Pattern PLACEHOLDER_PATTERN = Pattern
45+
.compile("\\{\\{(\\w+)(:.*?)?\\}\\}");
46+
47+
private final String content;
48+
49+
/**
50+
* Create a new {@link DefaultLaunchScript} instance.
51+
* @param file the source script file or {@code null} to use the default
52+
* @param properties an optional set of script properties used for variable expansion
53+
* @throws IOException if the script cannot be loaded
54+
*/
55+
public DefaultLaunchScript(File file, Map<?, ?> properties) throws IOException {
56+
String content = loadContent(file);
57+
this.content = expandPlaceholders(content, properties);
58+
}
59+
60+
private String loadContent(File file) throws IOException {
61+
if (file == null) {
62+
return loadContent(getClass().getResourceAsStream("launch.script"));
63+
}
64+
return loadContent(new FileInputStream(file));
65+
}
66+
67+
private String loadContent(InputStream inputStream) throws IOException {
68+
try {
69+
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
70+
copy(inputStream, outputStream);
71+
return new String(outputStream.toByteArray(), UTF_8);
72+
}
73+
finally {
74+
inputStream.close();
75+
}
76+
}
77+
78+
private void copy(InputStream inputStream, OutputStream outputStream)
79+
throws IOException {
80+
byte[] buffer = new byte[BUFFER_SIZE];
81+
int bytesRead = -1;
82+
while ((bytesRead = inputStream.read(buffer)) != -1) {
83+
outputStream.write(buffer, 0, bytesRead);
84+
}
85+
outputStream.flush();
86+
}
87+
88+
private String expandPlaceholders(String content, Map<?, ?> properties) {
89+
StringBuffer expanded = new StringBuffer();
90+
Matcher matcher = PLACEHOLDER_PATTERN.matcher(content);
91+
while (matcher.find()) {
92+
String name = matcher.group(1);
93+
String value = matcher.group(2);
94+
if (properties != null && properties.containsKey(name)) {
95+
value = (String) properties.get(name);
96+
}
97+
else {
98+
value = (value == null ? matcher.group(0) : value.substring(1));
99+
}
100+
matcher.appendReplacement(expanded, value);
101+
}
102+
matcher.appendTail(expanded);
103+
return expanded.toString();
104+
}
105+
106+
@Override
107+
public byte[] toByteArray() {
108+
return this.content.getBytes(UTF_8);
109+
}
110+
111+
}

spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
import java.io.InputStream;
2828
import java.io.OutputStream;
2929
import java.net.URL;
30+
import java.nio.file.Files;
31+
import java.nio.file.Path;
32+
import java.nio.file.attribute.PosixFilePermission;
3033
import java.util.Arrays;
3134
import java.util.Enumeration;
3235
import java.util.HashSet;
@@ -63,7 +66,37 @@ public class JarWriter {
6366
* @throws FileNotFoundException
6467
*/
6568
public JarWriter(File file) throws FileNotFoundException, IOException {
66-
this.jarOutput = new JarOutputStream(new FileOutputStream(file));
69+
this(file, null);
70+
}
71+
72+
/**
73+
* Create a new {@link JarWriter} instance.
74+
* @param file the file to write
75+
* @param launchScript an optional launch script to prepend to the front of the jar
76+
* @throws IOException
77+
* @throws FileNotFoundException
78+
*/
79+
public JarWriter(File file, LaunchScript launchScript) throws FileNotFoundException,
80+
IOException {
81+
FileOutputStream fileOutputStream = new FileOutputStream(file);
82+
if (launchScript != null) {
83+
fileOutputStream.write(launchScript.toByteArray());
84+
setExecutableFilePermission(file);
85+
}
86+
this.jarOutput = new JarOutputStream(fileOutputStream);
87+
}
88+
89+
private void setExecutableFilePermission(File file) {
90+
try {
91+
Path path = file.toPath();
92+
Set<PosixFilePermission> permissions = new HashSet<PosixFilePermission>(
93+
Files.getPosixFilePermissions(path));
94+
permissions.add(PosixFilePermission.OWNER_EXECUTE);
95+
Files.setPosixFilePermissions(path, permissions);
96+
}
97+
catch (Throwable ex) {
98+
// Ignore and continue creating the jar
99+
}
67100
}
68101

69102
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2012-2015 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.loader.tools;
18+
19+
/**
20+
* A script that can be prepended to the front of a JAR file to make it executable.
21+
*
22+
* @author Phillip Webb
23+
* @since 1.3.0
24+
*/
25+
public interface LaunchScript {
26+
27+
/**
28+
* The the content of the launch script as a byte array.
29+
* @return the script bytes
30+
*/
31+
byte[] toByteArray();
32+
33+
}

spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,17 +104,29 @@ public void repackage(Libraries libraries) throws IOException {
104104
* @throws IOException
105105
*/
106106
public void repackage(File destination, Libraries libraries) throws IOException {
107+
repackage(destination, libraries, null);
108+
}
109+
110+
/**
111+
* Repackage to the given destination so that it can be launched using '
112+
* {@literal java -jar}'
113+
* @param destination the destination file (may be the same as the source)
114+
* @param libraries the libraries required to run the archive
115+
* @param launchScript an optional launch script prepended to the front of the jar
116+
* @throws IOException
117+
* @since 1.3.0
118+
*/
119+
public void repackage(File destination, Libraries libraries, LaunchScript launchScript)
120+
throws IOException {
107121
if (destination == null || destination.isDirectory()) {
108122
throw new IllegalArgumentException("Invalid destination");
109123
}
110124
if (libraries == null) {
111125
throw new IllegalArgumentException("Libraries must not be null");
112126
}
113-
114127
if (alreadyRepackaged()) {
115128
return;
116129
}
117-
118130
destination = destination.getAbsoluteFile();
119131
File workingSource = this.source;
120132
if (this.source.equals(destination)) {
@@ -127,7 +139,7 @@ public void repackage(File destination, Libraries libraries) throws IOException
127139
try {
128140
JarFile jarFileSource = new JarFile(workingSource);
129141
try {
130-
repackage(jarFileSource, destination, libraries);
142+
repackage(jarFileSource, destination, libraries, launchScript);
131143
}
132144
finally {
133145
jarFileSource.close();
@@ -152,9 +164,9 @@ private boolean alreadyRepackaged() throws IOException {
152164
}
153165
}
154166

155-
private void repackage(JarFile sourceJar, File destination, Libraries libraries)
156-
throws IOException {
157-
final JarWriter writer = new JarWriter(destination);
167+
private void repackage(JarFile sourceJar, File destination, Libraries libraries,
168+
LaunchScript launchScript) throws IOException {
169+
final JarWriter writer = new JarWriter(destination, launchScript);
158170
try {
159171
final Set<String> seen = new HashSet<String>();
160172
writer.writeManifest(buildManifest(sourceJar));

0 commit comments

Comments
 (0)