001/*
002 * Copyright 2017-2022 original authors
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package io.micronaut.maven;
017
018import io.methvin.watcher.DirectoryChangeEvent;
019import io.methvin.watcher.DirectoryWatcher;
020import io.micronaut.maven.aot.AotAnalysisMojo;
021import io.micronaut.maven.services.CompilerService;
022import io.micronaut.maven.services.DependencyResolutionService;
023import io.micronaut.maven.services.ExecutorService;
024import io.micronaut.maven.testresources.AbstractTestResourcesMojo;
025import io.micronaut.maven.testresources.TestResourcesHelper;
026import io.micronaut.testresources.buildtools.ServerSettings;
027import io.micronaut.testresources.buildtools.ServerUtils;
028import org.apache.maven.execution.MavenSession;
029import org.apache.maven.model.FileSet;
030import org.apache.maven.plugin.BuildPluginManager;
031import org.apache.maven.plugin.MojoExecutionException;
032import org.apache.maven.plugins.annotations.Execute;
033import org.apache.maven.plugins.annotations.LifecyclePhase;
034import org.apache.maven.plugins.annotations.Mojo;
035import org.apache.maven.plugins.annotations.Parameter;
036import org.apache.maven.plugins.annotations.ResolutionScope;
037import org.apache.maven.project.MavenProject;
038import org.apache.maven.project.ProjectBuilder;
039import org.apache.maven.project.ProjectBuildingException;
040import org.apache.maven.project.ProjectBuildingRequest;
041import org.apache.maven.project.ProjectBuildingResult;
042import org.apache.maven.toolchain.ToolchainManager;
043import org.codehaus.plexus.util.AbstractScanner;
044import org.codehaus.plexus.util.cli.CommandLineUtils;
045import org.eclipse.aether.graph.Dependency;
046import org.eclipse.aether.util.artifact.JavaScopes;
047
048import javax.inject.Inject;
049import java.io.File;
050import java.nio.file.Files;
051import java.nio.file.InvalidPathException;
052import java.nio.file.Path;
053import java.util.ArrayList;
054import java.util.Arrays;
055import java.util.Collections;
056import java.util.List;
057import java.util.Optional;
058import java.util.concurrent.atomic.AtomicBoolean;
059import java.util.concurrent.locks.ReentrantLock;
060
061import static io.micronaut.maven.MojoUtils.findJavaExecutable;
062import static io.micronaut.maven.MojoUtils.hasMicronautMavenPlugin;
063import static java.nio.file.Files.isDirectory;
064import static java.nio.file.Files.isReadable;
065import static java.nio.file.LinkOption.NOFOLLOW_LINKS;
066
067/**
068 * <p>Executes a Micronaut application in development mode.</p>
069 *
070 * <p>It watches for changes in the project tree. If there are changes in the {@code pom.xml} file, dependencies will be reloaded. If
071 * the changes are anywhere underneath {@code src/main}, it will recompile the project and restart the application.</p>
072 *
073 * <p>The plugin can handle changes in all the languages supported by Micronaut: Java, Kotlin and Groovy.</p>
074 *
075 * @author Álvaro Sánchez-Mariscal
076 * @since 1.0.0
077 */
078@SuppressWarnings("unused")
079@Mojo(name = "run", requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME, defaultPhase = LifecyclePhase.PREPARE_PACKAGE, aggregator = true)
080@Execute(phase = LifecyclePhase.PROCESS_CLASSES)
081public class RunMojo extends AbstractTestResourcesMojo {
082
083    public static final String MN_APP_ARGS = "mn.appArgs";
084    public static final String EXEC_MAIN_CLASS = "${exec.mainClass}";
085    public static final String RESOURCES_DIR = "src/main/resources";
086    public static final String THIS_PLUGIN = "io.micronaut.maven:micronaut-maven-plugin";
087
088    private static final List<String> RELEVANT_SRC_DIRS = List.of("resources", "java", "kotlin", "groovy");
089    private static final int LAST_COMPILATION_THRESHOLD = 500;
090    private static final List<String> DEFAULT_EXCLUDES;
091
092    static {
093        DEFAULT_EXCLUDES = new ArrayList<>();
094        Collections.addAll(DEFAULT_EXCLUDES, AbstractScanner.DEFAULTEXCLUDES);
095        Collections.addAll(DEFAULT_EXCLUDES, "**/.idea/**", "**/src/test/**");
096    }
097
098    private final MavenSession mavenSession;
099    private final ProjectBuilder projectBuilder;
100    private final ToolchainManager toolchainManager;
101    private final String javaExecutable;
102    private final DependencyResolutionService dependencyResolutionService;
103    private final CompilerService compilerService;
104    private final ExecutorService executorService;
105
106    /**
107     * The project's target directory.
108     */
109    private File targetDirectory;
110
111    /**
112     * The main class of the application, as defined in the
113     * <a href="https://www.mojohaus.org/exec-maven-plugin/java-mojo.html#mainClass">Exec Maven Plugin</a>.
114     */
115    @Parameter(defaultValue = EXEC_MAIN_CLASS)
116    private String mainClass;
117
118    /**
119     * Whether to start the Micronaut application in debug mode.
120     */
121    @Parameter(property = "mn.debug", defaultValue = "false")
122    private boolean debug;
123
124    /**
125     * Whether to suspend the execution of the application when running in debug mode.
126     */
127    @Parameter(property = "mn.debug.suspend", defaultValue = "false")
128    private boolean debugSuspend;
129
130    /**
131     * The port where remote debuggers can be attached to.
132     */
133    @Parameter(property = "mn.debug.port", defaultValue = "5005")
134    private int debugPort;
135
136    /**
137     * The host where remote debuggers can connect.
138     */
139    @Parameter(property = "mn.debug.host", defaultValue = "127.0.0.1")
140    private String debugHost;
141
142    /**
143     * List of inclusion/exclusion paths that should not trigger an application restart.
144     * For example, you can exclude a particular directory from being watched by adding the following
145     * configuration:
146     * <pre>
147     *     &lt;watches&gt;
148     *         &lt;watch&gt;
149     *             &lt;directory&gt;.some-dir&lt;/directory&gt;
150     *             &lt;excludes&gt;
151     *                 &lt;exclude&gt;**&#47;*&lt;/exclude&gt;
152     *             &lt;/excludes&gt;
153     *         &lt;/watch&gt;
154     *     &lt;/watches&gt;
155     * </pre>
156     * Check the
157     * <a href="https://maven.apache.org/ref/3.3.9/maven-model/apidocs/org/apache/maven/model/FileSet.html">FileSet</a>
158     * documentation for more details.
159     *
160     * @see <a href="https://maven.apache.org/ref/3.3.9/maven-model/apidocs/org/apache/maven/model/FileSet.html">FileSet</a>
161     */
162    @Parameter
163    private List<FileSet> watches;
164
165    /**
166     * <p>List of additional arguments that will be passed to the JVM process, such as Java agent properties.</p>
167     *
168     * <p>When using the command line, user properties will be passed through, eg: <code>mnv mn:run -Dmicronaut.environments=dev</code>.</p>
169     */
170    @Parameter(property = "mn.jvmArgs")
171    private String jvmArguments;
172
173    /**
174     * List of additional arguments that will be passed to the application, after the class name.
175     */
176    @Parameter(property = MN_APP_ARGS)
177    private String appArguments;
178
179    /**
180     * Whether to watch for changes, or finish the execution after the first run.
181     */
182    @Parameter(property = "mn.watch", defaultValue = "true")
183    private boolean watchForChanges;
184
185    /**
186     * Whether to enable or disable Micronaut AOT.
187     */
188    @Parameter(property = "micronaut.aot.enabled", defaultValue = "false")
189    private boolean aotEnabled;
190
191    // These 2 flags are used in the context of watching for changes
192    // the first one makes sure that only one recompilation is processed at a time
193    private final AtomicBoolean recompileRequested = new AtomicBoolean();
194    // the second one makes sure that we wait for the server to be started before we restart it
195    // otherwise a process may be kept alive
196    private final ReentrantLock restartLock = new ReentrantLock();
197
198    private MavenProject runnableProject;
199    private DirectoryWatcher directoryWatcher;
200    private volatile Process process;
201    private String classpath;
202    private int classpathHash;
203    private long lastCompilation;
204    private TestResourcesHelper testResourcesHelper;
205
206    @SuppressWarnings("CdiInjectionPointsInspection")
207    @Inject
208    public RunMojo(MavenSession mavenSession,
209                   BuildPluginManager pluginManager,
210                   ProjectBuilder projectBuilder,
211                   ToolchainManager toolchainManager,
212                   CompilerService compilerService,
213                   ExecutorService executorService,
214                   DependencyResolutionService dependencyResolutionService) {
215        this.mavenSession = mavenSession;
216        this.projectBuilder = projectBuilder;
217        this.toolchainManager = toolchainManager;
218        this.compilerService = compilerService;
219        this.executorService = executorService;
220        this.javaExecutable = findJavaExecutable(toolchainManager, mavenSession);
221        this.dependencyResolutionService = dependencyResolutionService;
222    }
223
224    @Override
225    public void execute() throws MojoExecutionException {
226        try {
227            initialize();
228        } catch (Exception e) {
229            throw new MojoExecutionException(e.getMessage());
230        }
231
232        try {
233            maybeStartTestResourcesServer();
234            runApplication();
235            Thread shutdownHook = new Thread(this::killProcess);
236            Runtime.getRuntime().addShutdownHook(shutdownHook);
237
238            if (process != null && process.isAlive()) {
239                if (watchForChanges) {
240                    var pathsToWatch = new ArrayList<Path>();
241                    for (FileSet fs : watches) {
242                        var directory = runnableProject.getBasedir().toPath().resolve(fs.getDirectory()).toAbsolutePath();
243                        if (Files.exists(directory)) {
244                            pathsToWatch.add(directory);
245                            //If neither includes nor excludes, add a default include
246                            if ((fs.getIncludes() == null || fs.getIncludes().isEmpty()) && (fs.getExcludes() == null || fs.getExcludes().isEmpty())) {
247                                fs.addInclude("**/*");
248                            }
249                        }
250                    }
251
252                    this.directoryWatcher = DirectoryWatcher
253                        .builder()
254                        .paths(pathsToWatch)
255                        .listener(this::handleEvent)
256                        .build();
257
258                    // We use the working directory as the root path, because
259                    // the top-level project information may not be what we
260                    // expect, in particular if we run from a submodule, or
261                    // that we run from root but with the "-pl" option.
262                    // We can safely do this because it's only used to display
263                    // information about paths being watched to the user, the
264                    // actual paths are unchanged.
265                    Path root = Path.of(".").toAbsolutePath();
266                    List<Path> pathList = pathsToWatch.stream()
267                        .map(root::relativize)
268                        .filter(s -> !s.toString().isEmpty())
269                        .filter(Files::exists)
270                        .sorted()
271                        .toList();
272                    getLog().info("👀 Watching for changes in " + pathList);
273                    this.directoryWatcher.watch();
274                } else if (process != null && process.isAlive()) {
275                    process.waitFor();
276                }
277            }
278        } catch (InterruptedException e) {
279            Thread.currentThread().interrupt();
280        } catch (Exception e) {
281            if (getLog().isDebugEnabled()) {
282                getLog().debug("Exception while watching for changes", e);
283            }
284            throw new MojoExecutionException("Exception while watching for changes", e);
285        } finally {
286            killProcess();
287            cleanup();
288        }
289    }
290
291    protected final void initialize() {
292        final MavenProject currentProject = mavenSession.getCurrentProject();
293        if (hasMicronautMavenPlugin(currentProject)) {
294            runnableProject = currentProject;
295        } else {
296            final List<MavenProject> projectsWithPlugin = mavenSession.getProjects().stream()
297                .filter(MojoUtils::hasMicronautMavenPlugin)
298                .toList();
299            if (projectsWithPlugin.size() == 1) {
300                runnableProject = projectsWithPlugin.get(0);
301                log.info("Running project %s".formatted(runnableProject.getArtifactId()));
302            } else {
303                throw new IllegalStateException("The Micronaut Maven Plugin is declared in the following projects: %s. Please specify the project to run with the -pl option."
304                    .formatted(projectsWithPlugin.stream().map(MavenProject::getArtifactId).toList()));
305            }
306        }
307        this.targetDirectory = new File(runnableProject.getBuild().getDirectory());
308        this.testResourcesHelper = new TestResourcesHelper(testResourcesEnabled, shared, buildDirectory, explicitPort,
309                clientTimeout, serverIdleTimeoutMinutes, runnableProject, mavenSession, dependencyResolutionService,
310                toolchainManager, testResourcesVersion, classpathInference, testResourcesDependencies,
311                sharedServerNamespace, debugServer, false);
312        resolveDependencies();
313        if (watches == null) {
314            watches = new ArrayList<>();
315        }
316        // watch pom.xml file changes
317        mavenSession.getAllProjects().stream()
318            .filter(this::isDependencyOfRunnableProject)
319            .map(MavenProject::getBasedir)
320            .map(File::toPath)
321            .forEach(path -> {
322                var fileSet = new FileSet();
323                fileSet.setDirectory(path.toString());
324                fileSet.addInclude("pom.xml");
325                watches.add(fileSet);
326            });
327        // Add the default watch paths
328        mavenSession.getAllProjects().stream()
329            .filter(this::isDependencyOfRunnableProject)
330            .flatMap(p -> {
331                var basedir = p.getBasedir().toPath();
332                return RELEVANT_SRC_DIRS.stream().map(dir -> basedir.resolve("src/main/" + dir));
333            })
334            .forEach(path -> {
335                var fileSet = new FileSet();
336                fileSet.setDirectory(path.toString());
337                fileSet.addInclude("**/*");
338                watches.add(fileSet);
339            });
340    }
341
342    private boolean isDependencyOfRunnableProject(MavenProject mavenProject) {
343        return mavenProject.equals(runnableProject) || runnableProject.getDependencies().stream()
344            .anyMatch(d -> d.getGroupId().equals(mavenProject.getGroupId()) && d.getArtifactId().equals(mavenProject.getArtifactId()));
345    }
346
347    protected final void setWatches(List<FileSet> watches) {
348        this.watches = watches;
349    }
350
351    final void handleEvent(DirectoryChangeEvent event) {
352        Path path = event.path();
353        Path parent = path.getParent();
354        Path projectRootDirectory = mavenSession.getTopLevelProject().getBasedir().toPath();
355
356        if (matches(path)) {
357            if (getLog().isInfoEnabled()) {
358                getLog().info(String.format("📝 Detected change in %s. Recompiling/restarting...", projectRootDirectory.relativize(path)));
359            }
360            boolean compiledOk = compileProject();
361            if (compiledOk) {
362                try {
363                    runApplication();
364                } catch (Exception e) {
365                    getLog().error("Unable to run application: " + e.getMessage(), e);
366                }
367            }
368        }
369    }
370
371    private boolean matches(Path path) {
372        // Apply default exclusions
373        if (isDefaultExcluded(path) || isDirectory(path, NOFOLLOW_LINKS) || !isReadable(path) || hasBeenCompiledRecently()) {
374            return false;
375        }
376
377        Path projectRootDirectory = mavenSession.getTopLevelProject().getBasedir().toPath();
378
379        String relativePath = projectRootDirectory.relativize(path).toString();
380
381        boolean matches = false;
382        for (FileSet fileSet : watches) {
383            if (fileSet.getIncludes() != null && !fileSet.getIncludes().isEmpty()) {
384                var directory = new File(fileSet.getDirectory());
385                if (directory.exists() && path.getParent().startsWith(directory.getAbsolutePath())) {
386                    for (String includePattern : fileSet.getIncludes()) {
387                        if (pathMatches(includePattern, path) || patternEquals(path, includePattern, directory)) {
388                            matches = true;
389                            if (getLog().isDebugEnabled()) {
390                                getLog().debug("Path [" + relativePath + "] matched the include pattern [" + includePattern + "] of the directory [" + fileSet.getDirectory() + "]");
391                            }
392                            break;
393                        }
394                    }
395                }
396            }
397            if (matches) {
398                break;
399            }
400        }
401
402        // Finally, process excludes only if the path is matching
403        if (matches) {
404            for (FileSet fileSet : watches) {
405                if (fileSet.getExcludes() != null && !fileSet.getExcludes().isEmpty()) {
406                    File directory = new File(fileSet.getDirectory());
407                    if (directory.exists() && path.getParent().startsWith(directory.getAbsolutePath())) {
408                        for (String excludePattern : fileSet.getExcludes()) {
409                            if (pathMatches(excludePattern, path) || patternEquals(path, excludePattern, directory)) {
410                                matches = false;
411                                if (getLog().isDebugEnabled()) {
412                                    getLog().debug("Path [" + relativePath + "] matched the exclude pattern [" + excludePattern + "] of the directory [" + fileSet.getDirectory() + "]");
413                                }
414                                break;
415                            }
416                        }
417                    }
418                }
419                if (!matches) {
420                    break;
421                }
422            }
423        }
424
425        return matches;
426    }
427
428    private boolean isDefaultExcluded(Path path) {
429        boolean excludeTargetDirectory = true;
430        if (this.watches != null && !this.watches.isEmpty()) {
431            for (FileSet fileSet : this.watches) {
432                if (fileSet.getDirectory().equals(this.targetDirectory.getName())) {
433                    excludeTargetDirectory = false;
434                }
435            }
436        }
437        return (excludeTargetDirectory && path.startsWith(targetDirectory.getAbsolutePath())) ||
438            DEFAULT_EXCLUDES.stream()
439                .anyMatch(excludePattern -> pathMatches(excludePattern, path));
440    }
441
442    private boolean hasBeenCompiledRecently() {
443        return (System.currentTimeMillis() - lastCompilation) < LAST_COMPILATION_THRESHOLD;
444    }
445
446    private void cleanup() {
447        if (getLog().isDebugEnabled()) {
448            getLog().debug("Cleaning up");
449        }
450        try {
451            directoryWatcher.close();
452            maybeStopTestResourcesServer();
453        } catch (Exception e) {
454            // Do nothing
455        }
456    }
457
458    private boolean rebuildMavenProject() {
459        boolean success = true;
460        try {
461            ProjectBuildingRequest projectBuildingRequest = mavenSession.getProjectBuildingRequest();
462            projectBuildingRequest.setResolveDependencies(true);
463            ProjectBuildingResult build = projectBuilder.build(runnableProject.getArtifact(), projectBuildingRequest);
464            MavenProject project = build.getProject();
465            runnableProject = project;
466            mavenSession.setCurrentProject(project);
467        } catch (ProjectBuildingException e) {
468            success = false;
469            if (getLog().isWarnEnabled()) {
470                getLog().warn("Error while trying to build the Maven project model", e);
471            }
472        }
473        return success;
474    }
475
476    private boolean resolveDependencies() {
477        try {
478            List<Dependency> dependencies = compilerService.resolveDependencies(runnableProject, JavaScopes.PROVIDED, JavaScopes.COMPILE, JavaScopes.RUNTIME);
479            if (dependencies.isEmpty()) {
480                return false;
481            } else {
482                this.classpath = compilerService.buildClasspath(dependencies);
483                return true;
484            }
485        } finally {
486            if (classpath != null) {
487                this.classpathHash = this.classpath.hashCode();
488            }
489        }
490    }
491
492    private boolean classpathHasChanged() {
493        int oldClasspathHash = this.classpathHash;
494        this.classpathHash = this.classpath.hashCode();
495        return oldClasspathHash != classpathHash;
496
497    }
498
499    /**
500     * Runs or restarts the application. Only visible for testing, shouldn't
501     * be called directly.
502     *
503     * @throws Exception if something goes wrong while starting the application
504     */
505    protected void runApplication() throws Exception {
506        if (restartLock.getQueueLength() >= 1) {
507            // if there's more than one restart request, we'll handle them all at once
508            return;
509        }
510        restartLock.lock();
511        try {
512            runAotIfNeeded();
513            String classpathArgument = new File(targetDirectory, "classes" + File.pathSeparator).getAbsolutePath() + this.classpath;
514
515            var args = new ArrayList<String>();
516            args.add(javaExecutable);
517
518            if (debug) {
519                String suspend = debugSuspend ? "y" : "n";
520                args.add("-agentlib:jdwp=transport=dt_socket,server=y,suspend=" + suspend + ",address=" + debugHost + ":" + debugPort);
521            }
522
523            if (testResourcesEnabled) {
524                Path testResourcesSettingsDirectory = shared ? ServerUtils.getDefaultSharedSettingsPath(sharedServerNamespace) :
525                    AbstractTestResourcesMojo.serverSettingsDirectoryOf(targetDirectory.toPath());
526                Optional<ServerSettings> serverSettings = ServerUtils.readServerSettings(testResourcesSettingsDirectory);
527                serverSettings.ifPresent(settings -> testResourcesHelper.computeSystemProperties(settings)
528                    .forEach((k, v) -> args.add("-D" + k + "=" + v)));
529            }
530
531            if (jvmArguments != null && !jvmArguments.isEmpty()) {
532                final String[] strings = CommandLineUtils.translateCommandline(jvmArguments);
533                args.addAll(Arrays.asList(strings));
534            }
535
536            if (!mavenSession.getUserProperties().isEmpty()) {
537                mavenSession.getUserProperties().forEach((k, v) -> args.add("-D" + k + "=" + v));
538            }
539
540            if (mainClass == null) {
541                mainClass = runnableProject.getProperties().getProperty("exec.mainClass");
542            }
543
544            args.add("-classpath");
545            args.add(classpathArgument);
546            args.add("-XX:TieredStopAtLevel=1");
547            args.add("-Dcom.sun.management.jmxremote");
548            args.add(mainClass);
549
550            if (appArguments != null && !appArguments.isEmpty()) {
551                final String[] strings = CommandLineUtils.translateCommandline(appArguments);
552                args.addAll(Arrays.asList(strings));
553            }
554
555            if (getLog().isDebugEnabled()) {
556                getLog().debug("Running " + String.join(" ", args));
557            }
558
559            killProcess();
560            process = new ProcessBuilder(args)
561                .inheritIO()
562                .directory(targetDirectory)
563                .start();
564        } finally {
565            restartLock.unlock();
566        }
567    }
568
569    private void runAotIfNeeded() {
570        if (aotEnabled) {
571            try {
572                executorService.executeGoal(THIS_PLUGIN, AotAnalysisMojo.NAME);
573            } catch (MojoExecutionException e) {
574                getLog().error(e.getMessage());
575            }
576        }
577    }
578
579    private void maybeStartTestResourcesServer() throws MojoExecutionException {
580        testResourcesHelper.start();
581    }
582
583    private void maybeStopTestResourcesServer() throws MojoExecutionException {
584        testResourcesHelper.stop(false);
585    }
586
587    private boolean compileProject() {
588        // There can be multiple changes detected at the same time, so we want
589        // to keep only one compilation request
590        if (recompileRequested.get()) {
591            return false;
592        }
593        recompileRequested.set(true);
594        try {
595            return doCompile();
596        } finally {
597            recompileRequested.set(false);
598        }
599    }
600
601    private boolean doCompile() {
602        Optional<Long> lastCompilationMillis = compilerService.compileProject();
603        lastCompilationMillis.ifPresent(lc -> this.lastCompilation = lc);
604        return lastCompilationMillis.isPresent();
605    }
606
607    private void killProcess() {
608        if (process != null && process.isAlive()) {
609            if (getLog().isDebugEnabled()) {
610                getLog().debug("Stopping the background process");
611            }
612            process.destroy();
613            try {
614                process.waitFor();
615            } catch (InterruptedException e) {
616                process.destroyForcibly();
617                Thread.currentThread().interrupt();
618            }
619        }
620    }
621
622    private static String normalize(Path path) {
623        return path.toString().replace('\\', '/');
624    }
625
626    private static boolean pathMatches(String pattern, Path path) {
627        return AbstractScanner.match(pattern, normalize(path));
628    }
629
630    private static boolean patternEquals(Path path, String includePattern, File directory) {
631        try {
632            var testPath = normalize(directory.toPath().resolve(includePattern).toAbsolutePath());
633            return testPath.equals(normalize(path.toAbsolutePath()));
634        } catch (InvalidPathException ex) {
635            return false;
636        }
637    }
638
639}