Implement extract and list-layers command

Adds a new jarmode called 'tools'. This provides two commands,
'extract' and 'list-layers'. list-layers is the same as list from
the layertools.

extract is able to extract the JAR in four different modes:

- CDS compatible extraction with libraries in a lib folder and a runner
.jar
- CDS compatible as above, but with layers
- Launcher based
- Launcher based with layers. This is essentially the same as extract
  from the layertools

The commands in layertools have been deprecated in favor of the commands
in 'tools'.

This also changes the behavior of layers.enabled from the Gradle and
Maven plugin: before this commit, layers.enabled prevents the inclusion
of the layer index file as well as the layertools JAR.
After this commit, layers.enabled only prevents the inclusion of the
layer index file.

layer.includeLayerTools have been deprecated in favor of includeTools,
and the layertools JAR has been renamed to tools.

Closes gh-38276
This commit is contained in:
Moritz Halbritter
2024-02-09 15:04:46 +01:00
parent 2c4fb5baaa
commit 793aca60d2
121 changed files with 2780 additions and 612 deletions

View File

@@ -0,0 +1,390 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.jarmode.tools;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.stream.Stream;
/**
* A command that can be launched.
*
* @author Phillip Webb
* @author Scott Frederick
* @author Moritz Halbritter
*/
abstract class Command {
private final String name;
private final String description;
private final Options options;
private final Parameters parameters;
/**
* Create a new {@link Command} instance.
* @param name the name of the command
* @param description a description of the command
* @param options the command options
* @param parameters the command parameters
*/
Command(String name, String description, Options options, Parameters parameters) {
this.name = name;
this.description = description;
this.options = options;
this.parameters = parameters;
}
/**
* Return the name of this command.
* @return the command name
*/
String getName() {
return this.name;
}
/**
* Return the description of this command.
* @return the command description
*/
String getDescription() {
return this.description;
}
/**
* Return options that this command accepts.
* @return the command options
*/
Options getOptions() {
return this.options;
}
/**
* Return parameters that this command accepts.
* @return the command parameters
*/
Parameters getParameters() {
return this.parameters;
}
/**
* Run the command by processing the remaining arguments.
* @param out stream for command output
* @param args a mutable deque of the remaining arguments
*/
final void run(PrintStream out, Deque<String> args) {
List<String> parameters = new ArrayList<>();
Map<Option, String> options = new HashMap<>();
while (!args.isEmpty()) {
String arg = args.removeFirst();
Option option = this.options.find(arg);
if (option != null) {
options.put(option, option.claimArg(args));
}
else {
parameters.add(arg);
}
}
run(out, options, parameters);
}
/**
* Run the actual command.
* @param out stream for command output
* @param options any options extracted from the arguments
* @param parameters any parameters extracted from the arguments
*/
abstract void run(PrintStream out, Map<Option, String> options, List<String> parameters);
/**
* Whether the command is deprecated.
* @return whether the command is deprecated
*/
boolean isDeprecated() {
return false;
}
/**
* Returns the deprecation message.
* @return the deprecation message
*/
String getDeprecationMessage() {
return null;
}
/**
* Static method that can be used to find a single command from a collection.
* @param commands the commands to search
* @param name the name of the command to find
* @return a {@link Command} instance or {@code null}.
*/
static Command find(Collection<? extends Command> commands, String name) {
for (Command command : commands) {
if (command.getName().equals(name)) {
return command;
}
}
return null;
}
/**
* Parameters that the command accepts.
*/
static final class Parameters {
private final List<String> descriptions;
private Parameters(String[] descriptions) {
this.descriptions = Collections.unmodifiableList(Arrays.asList(descriptions));
}
/**
* Return the parameter descriptions.
* @return the descriptions
*/
List<String> getDescriptions() {
return this.descriptions;
}
@Override
public String toString() {
return this.descriptions.toString();
}
/**
* Factory method used if there are no expected parameters.
* @return a new {@link Parameters} instance
*/
static Parameters none() {
return of();
}
/**
* Factory method used to create a new {@link Parameters} instance with specific
* descriptions.
* @param descriptions the parameter descriptions
* @return a new {@link Parameters} instance with the given descriptions
*/
static Parameters of(String... descriptions) {
return new Parameters(descriptions);
}
}
/**
* Options that the command accepts.
*/
static final class Options {
private final Option[] values;
private Options(Option[] values) {
this.values = values;
}
private Option find(String arg) {
if (arg.startsWith("--")) {
String name = arg.substring(2);
for (Option candidate : this.values) {
if (candidate.getName().equals(name)) {
return candidate;
}
}
throw new UnknownOptionException(name);
}
return null;
}
/**
* Return if this options collection is empty.
* @return if there are no options
*/
boolean isEmpty() {
return this.values.length == 0;
}
/**
* Return a stream of each option.
* @return a stream of the options
*/
Stream<Option> stream() {
return Arrays.stream(this.values);
}
/**
* Factory method used if there are no expected options.
* @return a new {@link Options} instance
*/
static Options none() {
return of();
}
/**
* Factory method used to create a new {@link Options} instance with specific
* values.
* @param values the option values
* @return a new {@link Options} instance with the given values
*/
static Options of(Option... values) {
return new Options(values);
}
}
/**
* An individual option that the command can accepts. Can either be an option with a
* value (e.g. {@literal --log debug}) or a flag (e.g. {@literal
* --verbose}). It also can be both if the value is marked as optional.
*/
static final class Option {
private final String name;
private final String valueDescription;
private final String description;
private final boolean optionalValue;
private Option(String name, String valueDescription, String description, boolean optionalValue) {
this.name = name;
this.description = description;
this.valueDescription = valueDescription;
this.optionalValue = optionalValue;
}
/**
* Return the name of the option.
* @return the options name
*/
String getName() {
return this.name;
}
/**
* Return the description of the expected argument value or {@code null} if this
* option is a flag/switch.
* @return the option value description
*/
String getValueDescription() {
return this.valueDescription;
}
/**
* Return the name and the value description combined.
* @return the name and value description
*/
String getNameAndValueDescription() {
return this.name + ((this.valueDescription != null) ? " " + this.valueDescription : "");
}
/**
* Return a description of the option.
* @return the option description
*/
String getDescription() {
return this.description;
}
private String claimArg(Deque<String> args) {
if (this.valueDescription == null) {
return null;
}
if (this.optionalValue) {
String nextArg = args.peek();
if (nextArg == null || nextArg.startsWith("--")) {
return null;
}
return args.removeFirst();
}
else {
try {
return args.removeFirst();
}
catch (NoSuchElementException ex) {
throw new MissingValueException(this.name);
}
}
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
return this.name.equals(((Option) obj).name);
}
@Override
public int hashCode() {
return this.name.hashCode();
}
@Override
public String toString() {
return this.name;
}
/**
* Factory method to create a flag/switch option.
* @param name the name of the option
* @param description a description of the option
* @return a new {@link Option} instance
*/
static Option flag(String name, String description) {
return new Option(name, null, description, false);
}
/**
* Factory method to create value option.
* @param name the name of the option
* @param valueDescription a description of the expected value
* @param description a description of the option
* @return a new {@link Option} instance
*/
static Option of(String name, String valueDescription, String description) {
return new Option(name, valueDescription, description, false);
}
/**
* Factory method to create value option.
* @param name the name of the option
* @param valueDescription a description of the expected value
* @param description a description of the option
* @param optionalValue whether the value is optional
* @return a new {@link Option} instance
*/
static Option of(String name, String valueDescription, String description, boolean optionalValue) {
return new Option(name, valueDescription, description, optionalValue);
}
}
}

View File

@@ -0,0 +1,142 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.jarmode.tools;
import java.io.File;
import java.io.IOException;
import java.net.JarURLConnection;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.nio.file.Paths;
import java.security.CodeSource;
import java.security.ProtectionDomain;
import java.util.jar.JarFile;
import org.springframework.util.Assert;
/**
* Context for use by commands.
*
* @author Phillip Webb
*/
class Context {
private final File archiveFile;
private final File workingDir;
private final String relativeDir;
/**
* Create a new {@link Context} instance.
*/
Context() {
this(getSourceArchiveFile(), Paths.get(".").toAbsolutePath().normalize().toFile());
}
/**
* Create a new {@link Context} instance with the specified value.
* @param archiveFile the source archive file
* @param workingDir the working directory
*/
Context(File archiveFile, File workingDir) {
Assert.state(isExistingFile(archiveFile), "Unable to find source archive");
Assert.state(isJarOrWar(archiveFile), "Source archive " + archiveFile + " must end with .jar or .war");
this.archiveFile = archiveFile;
this.workingDir = workingDir;
this.relativeDir = deduceRelativeDir(archiveFile.getParentFile(), this.workingDir);
}
private boolean isExistingFile(File archiveFile) {
return archiveFile != null && archiveFile.isFile() && archiveFile.exists();
}
private boolean isJarOrWar(File jarFile) {
String name = jarFile.getName().toLowerCase();
return name.endsWith(".jar") || name.endsWith(".war");
}
private static File getSourceArchiveFile() {
try {
ProtectionDomain domain = Context.class.getProtectionDomain();
CodeSource codeSource = (domain != null) ? domain.getCodeSource() : null;
URL location = (codeSource != null) ? codeSource.getLocation() : null;
File source = (location != null) ? findSource(location) : null;
if (source != null && source.exists()) {
return source.getAbsoluteFile();
}
return null;
}
catch (Exception ex) {
return null;
}
}
private static File findSource(URL location) throws IOException, URISyntaxException {
URLConnection connection = location.openConnection();
if (connection instanceof JarURLConnection jarURLConnection) {
return getRootJarFile(jarURLConnection.getJarFile());
}
return new File(location.toURI());
}
private static File getRootJarFile(JarFile jarFile) {
String name = jarFile.getName();
int separator = name.indexOf("!/");
if (separator > 0) {
name = name.substring(0, separator);
}
return new File(name);
}
private String deduceRelativeDir(File sourceDirectory, File workingDir) {
String sourcePath = sourceDirectory.getAbsolutePath();
String workingPath = workingDir.getAbsolutePath();
if (sourcePath.equals(workingPath) || !sourcePath.startsWith(workingPath)) {
return null;
}
String relativePath = sourcePath.substring(workingPath.length() + 1);
return !relativePath.isEmpty() ? relativePath : null;
}
/**
* Return the source archive file that is running in tools mode.
* @return the archive file
*/
File getArchiveFile() {
return this.archiveFile;
}
/**
* Return the current working directory.
* @return the working dir
*/
File getWorkingDir() {
return this.workingDir;
}
/**
* Return the directory relative to {@link #getWorkingDir()} that contains the archive
* or {@code null} if none relative directory can be deduced.
* @return the relative dir ending in {@code /} or {@code null}
*/
String getRelativeArchiveDir() {
return this.relativeDir;
}
}

View File

@@ -0,0 +1,436 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.jarmode.tools;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.attribute.BasicFileAttributeView;
import java.nio.file.attribute.FileTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.boot.jarmode.tools.JarStructure.Entry;
import org.springframework.boot.jarmode.tools.JarStructure.Entry.Type;
import org.springframework.boot.jarmode.tools.Layers.LayersNotEnabledException;
import org.springframework.util.Assert;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
/**
* The {@code 'extract'} tools command.
*
* @author Moritz Halbritter
*/
class ExtractCommand extends Command {
/**
* Option to create a launcher.
*/
static final Option LAUNCHER_OPTION = Option.of("launcher", null, "Whether to extract the Spring Boot launcher");
/**
* Option to extract layers.
*/
static final Option LAYERS_OPTION = Option.of("layers", "string list", "Layers to extract", true);
/**
* Option to specify the destination to write to.
*/
static final Option DESTINATION_OPTION = Option.of("destination", "string",
"Directory to extract files to. Defaults to the current working directory");
private static final Option LIBRARIES_DIRECTORY_OPTION = Option.of("libraries", "string",
"Name of the libraries directory. Only applicable when not using --launcher. Defaults to lib/");
private static final Option RUNNER_FILENAME_OPTION = Option.of("runner-filename", "string",
"Name of the runner JAR file. Only applicable when not using --launcher. Defaults to runner.jar");
private final Context context;
private final Layers layers;
ExtractCommand(Context context) {
this(context, null);
}
ExtractCommand(Context context, Layers layers) {
super("extract", "Extract the contents from the jar", Options.of(LAUNCHER_OPTION, LAYERS_OPTION,
DESTINATION_OPTION, LIBRARIES_DIRECTORY_OPTION, RUNNER_FILENAME_OPTION), Parameters.none());
this.context = context;
this.layers = layers;
}
@Override
void run(PrintStream out, Map<Option, String> options, List<String> parameters) {
try {
checkJarCompatibility();
File destination = getWorkingDirectory(options);
FileResolver fileResolver = getFileResolver(destination, options);
fileResolver.createDirectories();
if (options.containsKey(LAUNCHER_OPTION)) {
extractArchive(fileResolver);
}
else {
JarStructure jarStructure = getJarStructure();
extractLibraries(fileResolver, jarStructure, options);
createRunner(jarStructure, fileResolver, options);
}
}
catch (IOException ex) {
throw new UncheckedIOException(ex);
}
catch (LayersNotEnabledException ex) {
printError(out, "Layers are not enabled");
}
}
private void checkJarCompatibility() throws IOException {
File file = this.context.getArchiveFile();
try (ZipInputStream stream = new ZipInputStream(new FileInputStream(file))) {
ZipEntry entry = stream.getNextEntry();
Assert.state(entry != null,
() -> "File '%s' is not compatible; ensure jar file is valid and launch script is not enabled"
.formatted(file));
}
}
private void printError(PrintStream out, String message) {
out.println("Error: " + message);
out.println();
}
private void extractLibraries(FileResolver fileResolver, JarStructure jarStructure, Map<Option, String> options)
throws IOException {
String librariesDirectory = getLibrariesDirectory(options);
extractArchive(fileResolver, (zipEntry) -> {
Entry entry = jarStructure.resolve(zipEntry);
if (isType(entry, Type.LIBRARY)) {
return librariesDirectory + entry.location();
}
return null;
});
}
private static String getLibrariesDirectory(Map<Option, String> options) {
if (options.containsKey(LIBRARIES_DIRECTORY_OPTION)) {
String value = options.get(LIBRARIES_DIRECTORY_OPTION);
if (value.endsWith("/")) {
return value;
}
return value + "/";
}
return "lib/";
}
private FileResolver getFileResolver(File destination, Map<Option, String> options) {
String runnerFilename = getRunnerFilename(options);
if (!options.containsKey(LAYERS_OPTION)) {
return new NoLayersFileResolver(destination, runnerFilename);
}
Layers layers = getLayers();
Set<String> layersToExtract = StringUtils.commaDelimitedListToSet(options.get(LAYERS_OPTION));
return new LayersFileResolver(destination, layers, layersToExtract, runnerFilename);
}
private File getWorkingDirectory(Map<Option, String> options) {
if (options.containsKey(DESTINATION_OPTION)) {
return new File(options.get(DESTINATION_OPTION));
}
return this.context.getWorkingDir();
}
private JarStructure getJarStructure() {
IndexedJarStructure jarStructure = IndexedJarStructure.get(this.context.getArchiveFile());
Assert.state(jarStructure != null, "Couldn't read classpath index");
return jarStructure;
}
private void extractArchive(FileResolver fileResolver) throws IOException {
extractArchive(fileResolver, ZipEntry::getName);
}
private void extractArchive(FileResolver fileResolver, EntryNameTransformer entryNameTransformer)
throws IOException {
withZipEntries(this.context.getArchiveFile(), (stream, zipEntry) -> {
if (zipEntry.isDirectory()) {
return;
}
String name = entryNameTransformer.getName(zipEntry);
if (name == null) {
return;
}
File file = fileResolver.resolve(zipEntry, name);
if (file != null) {
extractEntry(stream, zipEntry, file);
}
});
}
private Layers getLayers() {
if (this.layers != null) {
return this.layers;
}
return Layers.get(this.context);
}
private void createRunner(JarStructure jarStructure, FileResolver fileResolver, Map<Option, String> options)
throws IOException {
File file = fileResolver.resolveRunner();
if (file == null) {
return;
}
String librariesDirectory = getLibrariesDirectory(options);
Manifest manifest = jarStructure.createLauncherManifest((library) -> librariesDirectory + library);
mkDirs(file.getParentFile());
try (JarOutputStream output = new JarOutputStream(new FileOutputStream(file), manifest)) {
withZipEntries(this.context.getArchiveFile(), ((stream, zipEntry) -> {
Entry entry = jarStructure.resolve(zipEntry);
if (isType(entry, Type.APPLICATION_CLASS_OR_RESOURCE) && StringUtils.hasLength(entry.location())) {
JarEntry jarEntry = createJarEntry(entry.location(), zipEntry);
output.putNextEntry(jarEntry);
StreamUtils.copy(stream, output);
output.closeEntry();
}
}));
}
}
private String getRunnerFilename(Map<Option, String> options) {
if (options.containsKey(RUNNER_FILENAME_OPTION)) {
return options.get(RUNNER_FILENAME_OPTION);
}
return "runner.jar";
}
private static boolean isType(Entry entry, Type type) {
if (entry == null) {
return false;
}
return entry.type() == type;
}
private static void extractEntry(ZipInputStream zip, ZipEntry entry, File file) throws IOException {
mkDirs(file.getParentFile());
try (OutputStream out = new FileOutputStream(file)) {
StreamUtils.copy(zip, out);
}
try {
Files.getFileAttributeView(file.toPath(), BasicFileAttributeView.class)
.setTimes(entry.getLastModifiedTime(), entry.getLastAccessTime(), entry.getCreationTime());
}
catch (IOException ex) {
// File system does not support setting time attributes. Continue.
}
}
private static void mkDirs(File file) throws IOException {
if (!file.exists() && !file.mkdirs()) {
throw new IOException("Unable to create directory " + file);
}
}
private static JarEntry createJarEntry(String location, ZipEntry originalEntry) {
JarEntry jarEntry = new JarEntry(location);
FileTime lastModifiedTime = originalEntry.getLastModifiedTime();
if (lastModifiedTime != null) {
jarEntry.setLastModifiedTime(lastModifiedTime);
}
FileTime lastAccessTime = originalEntry.getLastAccessTime();
if (lastAccessTime != null) {
jarEntry.setLastAccessTime(lastAccessTime);
}
FileTime creationTime = originalEntry.getCreationTime();
if (creationTime != null) {
jarEntry.setCreationTime(creationTime);
}
return jarEntry;
}
private static void withZipEntries(File file, ThrowingConsumer callback) throws IOException {
try (ZipInputStream stream = new ZipInputStream(new FileInputStream(file))) {
ZipEntry entry = stream.getNextEntry();
while (entry != null) {
if (StringUtils.hasLength(entry.getName())) {
callback.accept(stream, entry);
}
entry = stream.getNextEntry();
}
}
}
private static File assertFileIsContainedInDirectory(File directory, File file, String name) throws IOException {
String canonicalOutputPath = directory.getCanonicalPath() + File.separator;
String canonicalEntryPath = file.getCanonicalPath();
Assert.state(canonicalEntryPath.startsWith(canonicalOutputPath),
() -> "Entry '%s' would be written to '%s'. This is outside the output location of '%s'. Verify the contents of your archive."
.formatted(name, canonicalEntryPath, canonicalOutputPath));
return file;
}
@FunctionalInterface
private interface EntryNameTransformer {
String getName(ZipEntry entry);
}
@FunctionalInterface
private interface ThrowingConsumer {
void accept(ZipInputStream stream, ZipEntry entry) throws IOException;
}
private interface FileResolver {
/**
* Creates needed directories.
* @throws IOException if something went wrong
*/
void createDirectories() throws IOException;
/**
* Resolves the given {@link ZipEntry} to a file.
* @param entry the zip entry
* @param newName the new name of the file
* @return file where the contents should be written or {@code null} if this entry
* should be skipped
* @throws IOException if something went wrong
*/
default File resolve(ZipEntry entry, String newName) throws IOException {
return resolve(entry.getName(), newName);
}
/**
* Resolves the given name to a file.
* @param originalName the original name of the file
* @param newName the new name of the file
* @return file where the contents should be written or {@code null} if this name
* should be skipped
* @throws IOException if something went wrong
*/
File resolve(String originalName, String newName) throws IOException;
/**
* Resolves the file for the runner.
* @return the file for the runner or {@code null} if the runner should be skipped
* @throws IOException if something went wrong
*/
File resolveRunner() throws IOException;
}
private static final class NoLayersFileResolver implements FileResolver {
private final File directory;
private final String runnerFilename;
private NoLayersFileResolver(File directory, String runnerFilename) {
this.directory = directory;
this.runnerFilename = runnerFilename;
}
@Override
public void createDirectories() {
}
@Override
public File resolve(String originalName, String newName) throws IOException {
return assertFileIsContainedInDirectory(this.directory, new File(this.directory, newName), newName);
}
@Override
public File resolveRunner() throws IOException {
return resolve(this.runnerFilename, this.runnerFilename);
}
}
private static final class LayersFileResolver implements FileResolver {
private final Layers layers;
private final Set<String> layersToExtract;
private final File directory;
private final String runnerFilename;
LayersFileResolver(File directory, Layers layers, Set<String> layersToExtract, String runnerFilename) {
this.layers = layers;
this.layersToExtract = layersToExtract;
this.directory = directory;
this.runnerFilename = runnerFilename;
}
@Override
public void createDirectories() throws IOException {
for (String layer : this.layers) {
if (shouldExtractLayer(layer)) {
mkDirs(getLayerDirectory(layer));
}
}
}
@Override
public File resolve(String originalName, String newName) throws IOException {
String layer = this.layers.getLayer(originalName);
if (shouldExtractLayer(layer)) {
File directory = getLayerDirectory(layer);
return assertFileIsContainedInDirectory(directory, new File(directory, newName), newName);
}
return null;
}
@Override
public File resolveRunner() throws IOException {
String layer = this.layers.getApplicationLayerName();
if (shouldExtractLayer(layer)) {
File directory = getLayerDirectory(layer);
return assertFileIsContainedInDirectory(directory, new File(directory, this.runnerFilename),
this.runnerFilename);
}
return null;
}
private File getLayerDirectory(String layer) {
return new File(this.directory, layer);
}
private boolean shouldExtractLayer(String layer) {
if (this.layersToExtract.isEmpty()) {
return true;
}
return this.layersToExtract.contains(layer);
}
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.jarmode.tools;
import java.io.PrintStream;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.util.StringUtils;
/**
* The {@code 'extract'} tools command.
*
* @author Phillip Webb
*/
class ExtractLayersCommand extends Command {
static final Option DESTINATION_OPTION = Option.of("destination", "string", "The destination to extract files to");
private final ExtractCommand delegate;
ExtractLayersCommand(Context context) {
this(context, null);
}
ExtractLayersCommand(Context context, Layers layers) {
super("extract", "Extracts layers from the jar for image creation", Options.of(DESTINATION_OPTION),
Parameters.of("[<layer>...]"));
this.delegate = new ExtractCommand(context, layers);
}
@Override
boolean isDeprecated() {
return true;
}
@Override
String getDeprecationMessage() {
return "Use '-Djarmode=tools extract --layers --launcher' instead.";
}
@Override
void run(PrintStream out, Map<Option, String> options, List<String> parameters) {
Map<Option, String> rewrittenOptions = new HashMap<>();
if (options.containsKey(DESTINATION_OPTION)) {
rewrittenOptions.put(ExtractCommand.DESTINATION_OPTION, options.get(DESTINATION_OPTION));
}
rewrittenOptions.put(ExtractCommand.LAYERS_OPTION, StringUtils.collectionToCommaDelimitedString(parameters));
rewrittenOptions.put(ExtractCommand.LAUNCHER_OPTION, null);
this.delegate.run(out, rewrittenOptions, Collections.emptyList());
}
}

View File

@@ -0,0 +1,144 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.jarmode.tools;
import java.io.PrintStream;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
/**
* Implicit {@code 'help'} command.
*
* @author Phillip Webb
* @author Moritz Halbritter
*/
class HelpCommand extends Command {
private final Context context;
private final List<Command> commands;
private final String jarMode;
HelpCommand(Context context, List<Command> commands) {
this(context, commands, System.getProperty("jarmode"));
}
HelpCommand(Context context, List<Command> commands, String jarMode) {
super("help", "Help about any command", Options.none(), Parameters.of("[<command>]"));
this.context = context;
this.commands = commands;
this.jarMode = (jarMode != null) ? jarMode : "tools";
}
@Override
void run(PrintStream out, Map<Option, String> options, List<String> parameters) {
run(out, parameters);
}
void run(PrintStream out, List<String> parameters) {
String commandName = (parameters.isEmpty()) ? null : parameters.get(0);
if (commandName == null) {
printUsageAndCommands(out);
return;
}
if (getName().equals(commandName)) {
printCommandHelp(out, this, true);
return;
}
Command command = Command.find(this.commands, commandName);
if (command == null) {
printError(out, "Unknown command \"%s\"".formatted(commandName));
printUsageAndCommands(out);
return;
}
printCommandHelp(out, command, true);
}
void printCommandHelp(PrintStream out, Command command, boolean printDeprecationWarning) {
if (command.isDeprecated() && printDeprecationWarning) {
printWarning(out, "This command is deprecated. " + command.getDeprecationMessage());
}
out.println(command.getDescription());
out.println();
out.println("Usage:");
out.println(" " + getJavaCommand() + " " + getUsage(command));
if (!command.getOptions().isEmpty()) {
out.println();
out.println("Options:");
int maxNameLength = getMaxLength(0, command.getOptions().stream().map(Option::getNameAndValueDescription));
command.getOptions().stream().forEach((option) -> printOptionSummary(out, option, maxNameLength));
}
}
private void printOptionSummary(PrintStream out, Option option, int padding) {
out.printf(" --%-" + padding + "s %s%n", option.getNameAndValueDescription(), option.getDescription());
}
private String getUsage(Command command) {
StringBuilder usage = new StringBuilder();
usage.append(command.getName());
if (!command.getOptions().isEmpty()) {
usage.append(" [options]");
}
command.getParameters().getDescriptions().forEach((param) -> usage.append(" ").append(param));
return usage.toString();
}
private void printUsageAndCommands(PrintStream out) {
out.println("Usage:");
out.println(" " + getJavaCommand());
out.println();
out.println("Available commands:");
int maxNameLength = getMaxLength(getName().length(), this.commands.stream().map(Command::getName));
this.commands.stream()
.filter((command) -> !command.isDeprecated())
.forEach((command) -> printCommandSummary(out, command, maxNameLength));
printCommandSummary(out, this, maxNameLength);
List<Command> deprecatedCommands = this.commands.stream().filter(Command::isDeprecated).toList();
if (!deprecatedCommands.isEmpty()) {
out.println("Deprecated commands:");
for (Command command : deprecatedCommands) {
printCommandSummary(out, command, maxNameLength);
}
}
}
private int getMaxLength(int minimum, Stream<String> strings) {
return Math.max(minimum, strings.mapToInt(String::length).max().orElse(0));
}
private void printCommandSummary(PrintStream out, Command command, int padding) {
out.printf(" %-" + padding + "s %s%n", command.getName(), command.getDescription());
}
private String getJavaCommand() {
return "java -Djarmode=" + this.jarMode + " -jar " + this.context.getArchiveFile().getName();
}
private void printError(PrintStream out, String errorMessage) {
out.println("Error: " + errorMessage);
out.println();
}
private void printWarning(PrintStream out, String errorMessage) {
out.println("Warning: " + errorMessage);
out.println();
}
}

View File

@@ -0,0 +1,159 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.jarmode.tools;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.NoSuchFileException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.UnaryOperator;
import java.util.jar.Attributes;
import java.util.jar.Attributes.Name;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import org.springframework.boot.jarmode.tools.JarStructure.Entry.Type;
import org.springframework.util.Assert;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
/**
* {@link JarStructure} implementation backed by a {@code classpath.idx} file.
*
* @author Stephane Nicoll
* @author Moritz Halbritter
*/
class IndexedJarStructure implements JarStructure {
private static final List<String> MANIFEST_DENY_LIST = List.of("Start-Class", "Spring-Boot-Classes",
"Spring-Boot-Lib", "Spring-Boot-Classpath-Index", "Spring-Boot-Layers-Index");
private final Manifest originalManifest;
private final String libLocation;
private final String classesLocation;
private final List<String> classpathEntries;
IndexedJarStructure(Manifest originalManifest, String indexFile) {
this.originalManifest = originalManifest;
this.libLocation = getLocation(originalManifest, "Spring-Boot-Lib");
this.classesLocation = getLocation(originalManifest, "Spring-Boot-Classes");
this.classpathEntries = readIndexFile(indexFile);
}
private static String getLocation(Manifest manifest, String attribute) {
String location = getMandatoryAttribute(manifest, attribute);
if (!location.endsWith("/")) {
location = location + "/";
}
return location;
}
private static List<String> readIndexFile(String indexFile) {
String[] lines = Arrays.stream(indexFile.split("\n"))
.map((line) -> line.replace("\r", ""))
.filter(StringUtils::hasText)
.toArray(String[]::new);
List<String> classpathEntries = new ArrayList<>();
for (String line : lines) {
if (line.startsWith("- ")) {
classpathEntries.add(line.substring(3, line.length() - 1));
}
else {
throw new IllegalStateException("Classpath index file is malformed");
}
}
Assert.state(!classpathEntries.isEmpty(), "Empty classpath index file loaded");
return classpathEntries;
}
@Override
public String getClassesLocation() {
return this.classesLocation;
}
@Override
public Entry resolve(String name) {
if (this.classpathEntries.contains(name)) {
return new Entry(name, toStructureDependency(name), Type.LIBRARY);
}
else if (name.startsWith(this.classesLocation)) {
return new Entry(name, name.substring(this.classesLocation.length()), Type.APPLICATION_CLASS_OR_RESOURCE);
}
else if (name.startsWith("org/springframework/boot/loader")) {
return new Entry(name, name, Type.LOADER);
}
return null;
}
@Override
public Manifest createLauncherManifest(UnaryOperator<String> libraryTransformer) {
Manifest manifest = new Manifest(this.originalManifest);
Attributes attributes = manifest.getMainAttributes();
for (String denied : MANIFEST_DENY_LIST) {
attributes.remove(new Name(denied));
}
attributes.put(Name.MAIN_CLASS, getMandatoryAttribute(this.originalManifest, "Start-Class"));
attributes.put(Name.CLASS_PATH,
this.classpathEntries.stream()
.map(this::toStructureDependency)
.map(libraryTransformer)
.collect(Collectors.joining(" ")));
return manifest;
}
private String toStructureDependency(String libEntryName) {
Assert.state(libEntryName.startsWith(this.libLocation), "Invalid library location " + libEntryName);
return libEntryName.substring(this.libLocation.length());
}
private static String getMandatoryAttribute(Manifest manifest, String attribute) {
String value = manifest.getMainAttributes().getValue(attribute);
Assert.state(value != null, "Manifest attribute '" + attribute + "' is mandatory");
return value;
}
static IndexedJarStructure get(File file) {
try {
try (JarFile jarFile = new JarFile(file)) {
Manifest manifest = jarFile.getManifest();
String location = getMandatoryAttribute(manifest, "Spring-Boot-Classpath-Index");
ZipEntry entry = jarFile.getEntry(location);
if (entry != null) {
String indexFile = StreamUtils.copyToString(jarFile.getInputStream(entry), StandardCharsets.UTF_8);
return new IndexedJarStructure(manifest, indexFile);
}
}
return null;
}
catch (FileNotFoundException | NoSuchFileException ex) {
return null;
}
catch (IOException ex) {
throw new IllegalStateException(ex);
}
}
}

View File

@@ -0,0 +1,123 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.jarmode.tools;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.NoSuchFileException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
import java.util.zip.ZipEntry;
import org.springframework.util.Assert;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
/**
* {@link Layers} implementation backed by a {@code layers.idx} file.
*
* @author Phillip Webb
* @author Madhura Bhave
* @author Moritz Halbritter
*/
class IndexedLayers implements Layers {
private final Map<String, List<String>> layers = new LinkedHashMap<>();
private final String classesLocation;
IndexedLayers(String indexFile, String classesLocation) {
this.classesLocation = classesLocation;
String[] lines = Arrays.stream(indexFile.split("\n"))
.map((line) -> line.replace("\r", ""))
.filter(StringUtils::hasText)
.toArray(String[]::new);
List<String> contents = null;
for (String line : lines) {
if (line.startsWith("- ")) {
contents = new ArrayList<>();
this.layers.put(line.substring(3, line.length() - 2), contents);
}
else if (line.startsWith(" - ")) {
Assert.notNull(contents, "Contents must not be null. Check if the index file is malformed!");
contents.add(line.substring(5, line.length() - 1));
}
else {
throw new IllegalStateException("Layer index file is malformed");
}
}
Assert.state(!this.layers.isEmpty(), "Empty layer index file loaded");
}
@Override
public String getApplicationLayerName() {
return getLayer(this.classesLocation);
}
@Override
public Iterator<String> iterator() {
return this.layers.keySet().iterator();
}
@Override
public String getLayer(String name) {
for (Map.Entry<String, List<String>> entry : this.layers.entrySet()) {
for (String candidate : entry.getValue()) {
if (candidate.equals(name) || (candidate.endsWith("/") && name.startsWith(candidate))) {
return entry.getKey();
}
}
}
throw new IllegalStateException("No layer defined in index for file '" + name + "'");
}
/**
* Get an {@link IndexedLayers} instance of possible.
* @param context the context
* @return an {@link IndexedLayers} instance or {@code null} if this not a layered
* jar.
*/
static IndexedLayers get(Context context) {
try {
try (JarFile jarFile = new JarFile(context.getArchiveFile())) {
Manifest manifest = jarFile.getManifest();
String location = manifest.getMainAttributes().getValue("Spring-Boot-Layers-Index");
ZipEntry entry = (location != null) ? jarFile.getEntry(location) : null;
if (entry != null) {
String indexFile = StreamUtils.copyToString(jarFile.getInputStream(entry), StandardCharsets.UTF_8);
String classesLocation = manifest.getMainAttributes().getValue("Spring-Boot-Classes");
return new IndexedLayers(indexFile, classesLocation);
}
}
return null;
}
catch (FileNotFoundException | NoSuchFileException ex) {
return null;
}
catch (IOException ex) {
throw new IllegalStateException(ex);
}
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.jarmode.tools;
import java.util.function.UnaryOperator;
import java.util.jar.Manifest;
import java.util.zip.ZipEntry;
/**
* Provide information about a fat jar structure that is meant to be extracted.
*
* @author Stephane Nicoll
* @author Moritz Halbritter
*/
interface JarStructure {
/**
* Resolve the specified {@link ZipEntry}, return {@code null} if the entry should not
* be handled.
* @param entry the entry to handle
* @return the resolved {@link Entry}
*/
default Entry resolve(ZipEntry entry) {
return resolve(entry.getName());
}
/**
* Resolve the entry with the specified name, return {@code null} if the entry should
* not be handled.
* @param name the name of the entry to handle
* @return the resolved {@link Entry}
*/
Entry resolve(String name);
/**
* Create the {@link Manifest} for the launcher jar, applying the specified operator
* on each classpath entry.
* @param libraryTransformer the operator to apply on each classpath entry
* @return the manifest to use for the launcher jar
*/
Manifest createLauncherManifest(UnaryOperator<String> libraryTransformer);
/**
* Return the location of the application classes.
* @return the location of the application classes
*/
String getClassesLocation();
/**
* An entry to handle in the exploded structure.
*
* @param originalLocation the original location
* @param location the relative location
* @param type of the entry
*/
record Entry(String originalLocation, String location, Type type) {
enum Type {
LIBRARY, APPLICATION_CLASS_OR_RESOURCE, LOADER
}
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.jarmode.tools;
import java.util.List;
import org.springframework.boot.loader.jarmode.JarMode;
/**
* {@link JarMode} providing {@code "layertools"} support.
*
* @author Phillip Webb
* @author Scott Frederick
* @since 2.3.0
*/
public class LayerToolsJarMode implements JarMode {
static Context contextOverride;
@Override
public boolean accepts(String mode) {
return "layertools".equalsIgnoreCase(mode);
}
@Override
public void run(String mode, String[] args) {
try {
Context context = (contextOverride != null) ? contextOverride : new Context();
new Runner(System.out, context, getCommands(context)).run(args);
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
static List<Command> getCommands(Context context) {
return List.of(new ListCommand(context), new ExtractLayersCommand(context));
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.jarmode.tools;
import java.util.Iterator;
import java.util.zip.ZipEntry;
/**
* Provides information about the jar layers.
*
* @author Phillip Webb
* @author Moritz Halbritter
* @see ExtractCommand
* @see ListCommand
*/
interface Layers extends Iterable<String> {
/**
* Return the jar layers in the order that they should be added (starting with the
* least frequently changed layer).
*/
@Override
Iterator<String> iterator();
/**
* Return the layer that a given entry is in.
* @param entry the entry to check
* @return the layer that the entry is in
*/
default String getLayer(ZipEntry entry) {
return getLayer(entry.getName());
}
/**
* Return the layer that the entry with the given name is in.
* @param entryName the name of the entry to check
* @return the layer that the entry is in
*/
String getLayer(String entryName);
/**
* Return the name of the application layer.
* @return the name of the application layer
*/
String getApplicationLayerName();
/**
* Return a {@link Layers} instance for the currently running application.
* @param context the command context
* @return a new layers instance
* @throws LayersNotEnabledException if layers are not enabled
*/
static Layers get(Context context) {
IndexedLayers indexedLayers = IndexedLayers.get(context);
if (indexedLayers == null) {
throw new LayersNotEnabledException();
}
return indexedLayers;
}
final class LayersNotEnabledException extends RuntimeException {
LayersNotEnabledException() {
super("Layers not enabled: Failed to load layer index file");
}
}
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.jarmode.tools;
import java.io.PrintStream;
import java.util.List;
import java.util.Map;
/**
* The {@code 'list'} tools command.
*
* Delegates the actual work to {@link ListLayersCommand}.
*
* @author Phillip Webb
* @author Moritz Halbritter
*/
class ListCommand extends Command {
private final ListLayersCommand delegate;
ListCommand(Context context) {
super("list", "List layers from the jar that can be extracted", Options.none(), Parameters.none());
this.delegate = new ListLayersCommand(context);
}
@Override
boolean isDeprecated() {
return true;
}
@Override
String getDeprecationMessage() {
return "Use '-Djarmode=tools list-layers' instead.";
}
@Override
void run(PrintStream out, Map<Option, String> options, List<String> parameters) {
this.delegate.run(out, options, parameters);
}
void printLayers(Layers layers, PrintStream out) {
this.delegate.printLayers(out, layers);
}
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.jarmode.tools;
import java.io.PrintStream;
import java.util.List;
import java.util.Map;
import org.springframework.boot.jarmode.tools.Layers.LayersNotEnabledException;
/**
* The {@code 'list-layers'} tools command.
*
* @author Moritz Halbritter
*/
class ListLayersCommand extends Command {
private final Context context;
ListLayersCommand(Context context) {
super("list-layers", "List layers from the jar that can be extracted", Options.none(), Parameters.none());
this.context = context;
}
@Override
void run(PrintStream out, Map<Option, String> options, List<String> parameters) {
try {
Layers layers = Layers.get(this.context);
printLayers(out, layers);
}
catch (LayersNotEnabledException ex) {
printError(out, "Layers are not enabled");
}
}
void printLayers(PrintStream out, Layers layers) {
layers.forEach(out::println);
}
private void printError(PrintStream out, String message) {
out.println("Error: " + message);
out.println();
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.jarmode.tools;
/**
* Exception thrown when a required value is not provided for an option.
*
* @author Scott Frederick
*/
class MissingValueException extends RuntimeException {
private final String optionName;
MissingValueException(String optionName) {
this.optionName = optionName;
}
@Override
public String getMessage() {
return "--" + this.optionName;
}
}

View File

@@ -0,0 +1,94 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.jarmode.tools;
import java.io.PrintStream;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Deque;
import java.util.List;
/**
* Runs commands.
*
* @author Moritz Halbritter
*/
class Runner {
private final PrintStream out;
private final List<Command> commands = new ArrayList<>();
private final HelpCommand help;
Runner(PrintStream out, Context context, List<Command> commands) {
this.out = out;
this.commands.addAll(commands);
this.help = new HelpCommand(context, commands);
this.commands.add(this.help);
}
void run(String... args) {
run(dequeOf(args));
}
private void run(Deque<String> args) {
if (!args.isEmpty()) {
String commandName = args.removeFirst();
Command command = Command.find(this.commands, commandName);
if (command != null) {
runCommand(command, args);
return;
}
printError("Unknown command \"" + commandName + "\"");
}
this.help.run(this.out, args);
}
private void runCommand(Command command, Deque<String> args) {
if (command.isDeprecated()) {
printWarning("This command is deprecated. " + command.getDeprecationMessage());
}
try {
command.run(this.out, args);
}
catch (UnknownOptionException ex) {
printError("Unknown option \"" + ex.getMessage() + "\" for the " + command.getName() + " command");
this.help.printCommandHelp(this.out, command, false);
}
catch (MissingValueException ex) {
printError("Option \"" + ex.getMessage() + "\" for the " + command.getName() + " command requires a value");
this.help.printCommandHelp(this.out, command, false);
}
}
private void printWarning(String message) {
this.out.println("Warning: " + message);
this.out.println();
}
private void printError(String message) {
this.out.println("Error: " + message);
this.out.println();
}
private Deque<String> dequeOf(String... args) {
return new ArrayDeque<>(Arrays.asList(args));
}
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.jarmode.tools;
import java.io.PrintStream;
import java.util.List;
import org.springframework.boot.loader.jarmode.JarMode;
/**
* {@link JarMode} providing {@code "tools"} support.
*
* @author Moritz Halbritter
* @since 3.3.0
*/
public class ToolsJarMode implements JarMode {
private final Context context;
private final PrintStream out;
public ToolsJarMode() {
this(null, null);
}
public ToolsJarMode(Context context, PrintStream out) {
this.context = (context != null) ? context : new Context();
this.out = (out != null) ? out : System.out;
}
@Override
public boolean accepts(String mode) {
return "tools".equalsIgnoreCase(mode);
}
@Override
public void run(String mode, String[] args) {
try {
new Runner(this.out, this.context, getCommands(this.context)).run(args);
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
static List<Command> getCommands(Context context) {
return List.of(new ExtractCommand(context), new ListLayersCommand(context));
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.jarmode.tools;
/**
* Exception thrown when an unrecognized option is encountered.
*
* @author Scott Frederick
*/
class UnknownOptionException extends RuntimeException {
private final String optionName;
UnknownOptionException(String optionName) {
this.optionName = optionName;
}
@Override
public String getMessage() {
return "--" + this.optionName;
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* JarMode support for layertools and tools.
*/
package org.springframework.boot.jarmode.tools;

View File

@@ -0,0 +1,4 @@
# Jar Modes
org.springframework.boot.loader.jarmode.JarMode=\
org.springframework.boot.jarmode.tools.LayerToolsJarMode,\
org.springframework.boot.jarmode.tools.ToolsJarMode