001/*
002 * Licensed under the Apache License, Version 2.0 (the "License");
003 * you may not use this file except in compliance with the License.
004 * You may obtain a copy of the License at
005 *
006 * http://www.apache.org/licenses/LICENSE-2.0
007 *
008 * Unless required by applicable law or agreed to in writing, software
009 * distributed under the License is distributed on an "AS IS" BASIS,
010 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
011 * See the License for the specific language governing permissions and
012 * limitations under the License.
013 */
014
015package org.basepom.mojo.repack;
016
017import static com.google.common.base.Preconditions.checkNotNull;
018import static com.google.common.base.Preconditions.checkState;
019import static com.google.common.collect.ImmutableSet.toImmutableSet;
020
021import java.io.File;
022import java.io.IOException;
023import java.nio.file.attribute.FileTime;
024import java.time.OffsetDateTime;
025import java.util.Arrays;
026import java.util.Set;
027import java.util.concurrent.TimeUnit;
028import java.util.regex.Pattern;
029
030import com.google.common.base.Strings;
031import com.google.common.collect.ImmutableSet;
032import org.apache.maven.artifact.Artifact;
033import org.apache.maven.execution.MavenSession;
034import org.apache.maven.plugin.AbstractMojo;
035import org.apache.maven.plugin.MojoExecutionException;
036import org.apache.maven.plugins.annotations.Component;
037import org.apache.maven.plugins.annotations.LifecyclePhase;
038import org.apache.maven.plugins.annotations.Mojo;
039import org.apache.maven.plugins.annotations.Parameter;
040import org.apache.maven.plugins.annotations.ResolutionScope;
041import org.apache.maven.project.MavenProject;
042import org.apache.maven.project.MavenProjectHelper;
043import org.apache.maven.shared.artifact.filter.collection.ArtifactFilterException;
044import org.apache.maven.shared.artifact.filter.collection.FilterArtifacts;
045import org.springframework.boot.loader.tools.Layers;
046import org.springframework.boot.loader.tools.LayoutFactory;
047import org.springframework.boot.loader.tools.Libraries;
048import org.springframework.boot.loader.tools.Repackager;
049
050/**
051 * Repack archives for execution using {@literal java -jar}. Can also be used to repack a jar with nested dependencies by using <code>layout=NONE</code>.
052 */
053@Mojo(name = "repack", defaultPhase = LifecyclePhase.PACKAGE, requiresProject = true, threadSafe = true,
054        requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME,
055        requiresDependencyCollection = ResolutionScope.COMPILE_PLUS_RUNTIME)
056public final class RepackMojo extends AbstractMojo {
057
058    private static final Pattern WHITE_SPACE_PATTERN = Pattern.compile("\\s+");
059
060    private static final PluginLog LOG = new PluginLog(RepackMojo.class);
061
062    @Parameter(defaultValue = "${project}", readonly = true, required = true)
063    MavenProject project;
064
065    @Parameter(defaultValue = "${session}", readonly = true, required = true)
066    MavenSession session;
067
068    @Component
069    MavenProjectHelper projectHelper;
070
071    /**
072     * The name of the main class. If not specified the first compiled class found that contains a {@code main} method will be used.
073     */
074    @Parameter(property = "repack.main-class")
075    String mainClass = null;
076
077    /**
078     * Collection of artifact definitions to include.
079     */
080    private Set<DependencyDefinition> includedDependencies = ImmutableSet.of();
081
082    // called by maven
083    @Parameter(alias = "includes")
084    public void setIncludedDependencies(final String... includedDependencies) {
085        checkNotNull(includedDependencies, "includedDependencies is null");
086
087        this.includedDependencies = Arrays.stream(includedDependencies)
088                .map(DependencyDefinition::new)
089                .collect(toImmutableSet());
090    }
091
092    /**
093     * Collection of artifact definitions to exclude.
094     */
095    private Set<DependencyDefinition> excludedDependencies = ImmutableSet.of();
096
097    // called by maven
098    @Parameter(alias = "excludedDependencies")
099    public void setExcludedDependencies(final String... excludedDependencies) {
100        checkNotNull(excludedDependencies, "excludedDependencies is null");
101
102        this.excludedDependencies = Arrays.stream(excludedDependencies)
103                .map(DependencyDefinition::new)
104                .collect(toImmutableSet());
105    }
106
107    /**
108     * Include system scoped dependencies.
109     */
110    @Parameter(defaultValue = "false", property = "repack.include-system-scope")
111    boolean includeSystemScope = false;
112
113    /**
114     * Include provided scoped dependencies.
115     */
116    @Parameter(defaultValue = "false", property = "repack.include-provided-scope")
117    boolean includeProvidedScope = false;
118
119    /**
120     * Include optional dependencies
121     */
122    @Parameter(defaultValue = "false", property = "repack.include-optional")
123    boolean includeOptional = false;
124
125    /**
126     * Directory containing the generated archive.
127     */
128    @Parameter(defaultValue = "${project.build.directory}", property = "repack.output-directory")
129    File outputDirectory;
130
131    /**
132     * Name of the generated archive.
133     */
134    @Parameter(defaultValue = "${project.build.finalName}", property = "repack.final-name")
135    String finalName;
136
137    /**
138     * Skip the execution.
139     */
140    @Parameter(defaultValue = "false", property = "repack.skip")
141    boolean skip = false;
142
143    /**
144     * Silence all non-output and non-error messages.
145     */
146    @Parameter(defaultValue = "false", property = "repack.quiet")
147    boolean quiet = false;
148
149    /**
150     * Do a summary report.
151     */
152    @Parameter(defaultValue = "true", property = "repack.report")
153    boolean report = true;
154
155    /**
156     * Classifier to add to the repacked archive. Use the blank string to replace the main artifact.
157     */
158    @Parameter(defaultValue = "repacked", property = "repack.classifier")
159    String repackClassifier = "repacked";
160
161    /**
162     * Attach the repacked archive to the build cycle.
163     */
164    @Parameter(defaultValue = "true", property = "repack.attach-artifact")
165    boolean attachRepackedArtifact = true;
166
167    /**
168     * A list of the libraries that must be unpacked at runtime (do not work within the fat jar).
169     */
170    private Set<DependencyDefinition> runtimeUnpackedDependencies = ImmutableSet.of();
171
172    // called by maven
173    @Parameter
174    public void setRuntimeUnpackedDependencies(final String... runtimeUnpackedDependencies) {
175        checkNotNull(runtimeUnpackedDependencies, "runtimeUnpackDependencies is null");
176
177        this.runtimeUnpackedDependencies = Arrays.stream(runtimeUnpackedDependencies)
178                .map(DependencyDefinition::new)
179                .collect(toImmutableSet());
180    }
181
182    /**
183     * A list of optional libraries that should be included even if optional dependencies are not included by default.
184     */
185    private Set<DependencyDefinition> optionalDependencies = ImmutableSet.of();
186
187    // called by maven
188    @Parameter
189    public void setOptionalDependencies(final String... optionalDependencies) {
190        checkNotNull(optionalDependencies, "optionalDependencies is null");
191
192        this.optionalDependencies = Arrays.stream(optionalDependencies)
193                .map(DependencyDefinition::new)
194                .collect(toImmutableSet());
195    }
196
197    /**
198     * Timestamp for reproducible output archive entries, either formatted as ISO 8601 (<code>yyyy-MM-dd'T'HH:mm:ssXXX</code>) or an {@code int} representing
199     * seconds since the epoch.
200     */
201    @Parameter(defaultValue = "${project.build.outputTimestamp}", property = "repack.output-timestamp")
202    String outputTimestamp;
203
204    /**
205     * The type of archive (which corresponds to how the dependencies are laid out inside it). Possible values are {@code JAR}, {@code WAR}, {@code ZIP},
206     * {@code DIR}, {@code NONE}. Defaults to {@code JAR}.
207     */
208    @Parameter(defaultValue = "JAR", property = "repack.layout")
209    LayoutType layout = LayoutType.JAR;
210
211    /**
212     * The layout factory that will be used to create the executable archive if no explicit layout is set. Alternative layouts implementations can be provided
213     * by 3rd parties.
214     */
215    @Parameter
216    LayoutFactory layoutFactory = null;
217
218
219    @Override
220    public void execute() throws MojoExecutionException {
221
222        if (skip) {
223            LOG.report(quiet, "Skipping plugin execution");
224            return;
225        }
226
227        if ("pom".equals(project.getPackaging())) {
228            LOG.report(quiet, "Ignoring POM project");
229            return;
230        }
231
232        checkState(this.outputDirectory != null, "output directory was unset!");
233        checkState(this.outputDirectory.exists(), "output directory '%s' does not exist!", this.outputDirectory.getAbsolutePath());
234
235        if (Strings.nullToEmpty(finalName).isBlank()) {
236            this.finalName = project.getArtifactId() + '-' + project.getVersion();
237            LOG.report(quiet, "Final name unset, falling back to %s", this.finalName);
238        }
239
240        if (Strings.nullToEmpty(repackClassifier).isBlank()) {
241            if (Strings.nullToEmpty(project.getArtifact().getClassifier()).isBlank()) {
242                LOG.report(quiet, "Repacked archive will replace main artifact");
243            } else {
244                LOG.report(quiet, "Repacked archive will have no classifier, main artifact has classifier '%s'", project.getArtifact().getClassifier());
245            }
246        } else {
247            if (repackClassifier.equals(project.getArtifact().getClassifier())) {
248                LOG.report(quiet, "Repacked archive will replace main artifact using classifier '%s'", repackClassifier);
249            } else {
250                LOG.report(quiet, "Repacked archive will use classifier '%s', main artifact has %s", repackClassifier,
251                        project.getArtifact().getClassifier() == null ? "no classifier" : "classifier '" + project.getArtifact().getClassifier() + "'");
252            }
253        }
254
255        try {
256            Artifact source = project.getArtifact();
257
258            Repackager repackager = new Repackager(source.getFile());
259
260            if (mainClass != null && !mainClass.isEmpty()) {
261                repackager.setMainClass(mainClass);
262            } else {
263                repackager.addMainClassTimeoutWarningListener((duration, mainMethod) ->
264                        LOG.warn("Searching for the main class is taking some time, "
265                                + "consider using the mainClass configuration parameter."));
266            }
267
268            if (layoutFactory != null) {
269                LOG.report(quiet, "Using %s Layout Factory to repack the %s artifact.", layoutFactory.getClass().getSimpleName(), project.getArtifact());
270                repackager.setLayoutFactory(layoutFactory);
271            } else if (layout != null) {
272                LOG.report(quiet, "Using %s Layout to repack the %s artifact.", layout, project.getArtifact());
273                repackager.setLayout(layout.layout());
274            } else {
275                LOG.warn("Neither Layout Factory nor Layout defined, resulting archive may be non-functional.");
276            }
277
278            repackager.setLayers(Layers.IMPLICIT);
279            // tools need spring framework dependencies which are not guaranteed to be there. So turn this off.
280            repackager.setIncludeRelevantJarModeJars(false);
281
282            File targetFile = getTargetFile();
283            Libraries libraries = getLibraries();
284            FileTime outputFileTimestamp = parseOutputTimestamp();
285
286            repackager.repackage(targetFile, libraries, null, outputFileTimestamp);
287
288            boolean repackReplacesSource = source.getFile().equals(targetFile);
289
290            if (attachRepackedArtifact) {
291                if (repackReplacesSource) {
292                    source.setFile(targetFile);
293                } else {
294                    projectHelper.attachArtifact(project, project.getPackaging(), Strings.emptyToNull(repackClassifier), targetFile);
295                }
296            } else if (repackReplacesSource && repackager.getBackupFile().exists()) {
297                source.setFile(repackager.getBackupFile());
298            } else if (!repackClassifier.isEmpty()) {
299                LOG.report(quiet, "Created repacked archive %s with classifier %s!", targetFile, repackClassifier);
300            }
301
302            if (report) {
303                Reporter.report(quiet, source, repackClassifier);
304            }
305        } catch (IOException ex) {
306            throw new MojoExecutionException(ex.getMessage(), ex);
307        }
308    }
309
310    private File getTargetFile() {
311        StringBuilder targetFileName = new StringBuilder();
312
313        targetFileName.append(finalName);
314
315        if (!repackClassifier.isEmpty()) {
316            targetFileName.append('-').append(repackClassifier);
317        }
318
319        targetFileName.append('.').append(project.getArtifact().getArtifactHandler().getExtension());
320
321        return new File(outputDirectory, targetFileName.toString());
322    }
323
324    /**
325     * Return {@link Libraries} that the packager can use.
326     */
327    private Libraries getLibraries() throws MojoExecutionException {
328
329        try {
330            Set<Artifact> artifacts = ImmutableSet.copyOf(project.getArtifacts());
331            Set<Artifact> includedArtifacts = ImmutableSet.copyOf(buildFilters().filter(artifacts));
332            return new ArtifactsLibraries(quiet, artifacts, includedArtifacts, session.getProjects(), runtimeUnpackedDependencies);
333        } catch (ArtifactFilterException ex) {
334            throw new MojoExecutionException(ex.getMessage(), ex);
335        }
336    }
337
338    private FilterArtifacts buildFilters() {
339
340        FilterArtifacts filters = new FilterArtifacts();
341
342        // remove all system scope artifacts
343        if (!includeSystemScope) {
344            filters.addFilter(new ScopeExclusionFilter(Artifact.SCOPE_SYSTEM));
345        }
346
347        // remove all provided scope artifacts
348        if (!includeProvidedScope) {
349            filters.addFilter(new ScopeExclusionFilter(Artifact.SCOPE_PROVIDED));
350        }
351
352        // if optionals are not included by default, filter out anything that is not included
353        // through a matcher
354        if (!includeOptional) {
355            filters.addFilter(new OptionalArtifactFilter(optionalDependencies));
356        }
357
358        // add includes filter. If no includes are given, don't add a filter (everything is included)
359        if (!includedDependencies.isEmpty()) {
360            // an explicit include list given.
361            filters.addFilter(new DependencyDefinitionFilter(includedDependencies, true));
362        }
363
364        // add excludes filter. If no excludes are given, don't add a filter (nothing gets excluded)
365        if (!excludedDependencies.isEmpty()) {
366            filters.addFilter(new DependencyDefinitionFilter(excludedDependencies, false));
367        }
368
369        return filters;
370    }
371
372    private FileTime parseOutputTimestamp() {
373        // Maven ignore a single-character timestamp as it is "useful to override a full
374        // value during pom inheritance"
375        if (outputTimestamp == null || outputTimestamp.length() < 2) {
376            return null;
377        }
378
379        long timestamp;
380
381        try {
382            timestamp = Long.parseLong(outputTimestamp);
383        } catch (NumberFormatException ex) {
384            timestamp = OffsetDateTime.parse(outputTimestamp).toInstant().getEpochSecond();
385        }
386
387        return FileTime.from(timestamp, TimeUnit.SECONDS);
388    }
389}