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.testresources;
017
018import io.micronaut.core.io.socket.SocketUtils;
019import io.micronaut.maven.services.DependencyResolutionService;
020import io.micronaut.testresources.buildtools.MavenDependency;
021import io.micronaut.testresources.buildtools.ModuleIdentifier;
022import io.micronaut.testresources.buildtools.ServerFactory;
023import io.micronaut.testresources.buildtools.ServerSettings;
024import io.micronaut.testresources.buildtools.ServerUtils;
025import io.micronaut.testresources.buildtools.TestResourcesClasspath;
026import org.apache.maven.execution.MavenSession;
027import org.apache.maven.model.Dependency;
028import org.apache.maven.plugin.MojoExecutionException;
029import org.apache.maven.plugin.logging.Log;
030import org.apache.maven.plugin.logging.SystemStreamLog;
031import org.apache.maven.project.MavenProject;
032import org.apache.maven.toolchain.ToolchainManager;
033import org.eclipse.aether.artifact.Artifact;
034import org.eclipse.aether.resolution.DependencyResolutionException;
035
036import java.io.File;
037import java.nio.file.DirectoryNotEmptyException;
038import java.nio.file.FileAlreadyExistsException;
039import java.io.IOException;
040import java.io.InputStream;
041import java.io.OutputStream;
042import java.net.HttpURLConnection;
043import java.net.URI;
044import java.nio.charset.StandardCharsets;
045import java.nio.file.Files;
046import java.nio.file.LinkOption;
047import java.nio.file.Path;
048import java.nio.file.StandardCopyOption;
049import java.nio.file.StandardOpenOption;
050import java.nio.file.attribute.FileAttribute;
051import java.nio.file.attribute.PosixFilePermissions;
052import java.util.Collections;
053import java.util.HashMap;
054import java.util.HashSet;
055import java.util.LinkedHashMap;
056import java.util.List;
057import java.util.Map;
058import java.util.Optional;
059import java.util.Properties;
060import java.util.Set;
061import java.util.WeakHashMap;
062import java.util.UUID;
063import java.util.concurrent.ConcurrentHashMap;
064import java.util.concurrent.atomic.AtomicBoolean;
065import java.util.concurrent.locks.ReentrantLock;
066import java.util.stream.Stream;
067
068import static io.micronaut.maven.services.DependencyResolutionService.toClasspathFiles;
069import static io.micronaut.testresources.buildtools.ServerUtils.PROPERTIES_FILE_NAME;
070import static java.util.stream.Stream.concat;
071
072/**
073 * Utility class to stop Test Resources service.
074 */
075public class TestResourcesHelper {
076
077    private static final String TEST_RESOURCES_PROPERTIES = PROPERTIES_FILE_NAME;
078    private static final String PORT_FILE_NAME = "test-resources-port.txt";
079    private static final String APPLICATION_TEST_PROPERTIES = "application-test.properties";
080    private static final String TEST_RESOURCES_SCOPE_PROPERTY = "micronaut.test.resources.scope";
081    private static final String SCOPE_PREFIX = "mvn";
082    private static final String TEST_RESOURCES_REQUIREMENTS_ENTRIES_PATH = "/requirements/entries";
083    private static final String ACCESS_TOKEN_HEADER = "Access-Token";
084    private static final int SERVER_PROBE_TIMEOUT_MS = 1_000;
085
086    private static final String TEST_RESOURCES_CLIENT_SYSTEM_PROP_PREFIX = "micronaut.test.resources.";
087
088    private static final String TEST_RESOURCES_PROP_SERVER_URI = TEST_RESOURCES_CLIENT_SYSTEM_PROP_PREFIX + "server.uri";
089    private static final String TEST_RESOURCES_PROP_ACCESS_TOKEN = TEST_RESOURCES_CLIENT_SYSTEM_PROP_PREFIX + "server.access.token";
090    private static final String TEST_RESOURCES_PROP_CLIENT_READ_TIMEOUT = TEST_RESOURCES_CLIENT_SYSTEM_PROP_PREFIX + "server.client.read.timeout";
091    private static final Object SESSION_STATE_MONITOR = new Object();
092    private static final Map<MavenSession, SessionState> SESSION_STATES = new WeakHashMap<>();
093    private static final Map<Path, ReentrantLock> SHARED_SERVER_LOCKS = new ConcurrentHashMap<>();
094
095    private final boolean enabled;
096
097    private final MavenSession mavenSession;
098
099    private final boolean shared;
100
101    private final File buildDirectory;
102
103    private final Log log;
104
105    private Integer explicitPort;
106
107    private Integer clientTimeout;
108
109    private Integer serverIdleTimeoutMinutes;
110
111    private MavenProject mavenProject;
112
113    private DependencyResolutionService dependencyResolutionService;
114
115    private ToolchainManager toolchainManager;
116
117    private String testResourcesVersion;
118
119    private boolean classpathInference;
120
121    private List<Dependency> testResourcesDependencies;
122
123    private String sharedServerNamespace;
124
125    private boolean debugServer;
126
127    private boolean foreground = false;
128
129    private Map<String, String> testResourcesSystemProperties;
130
131    public TestResourcesHelper(boolean enabled,
132                               boolean shared,
133                               File buildDirectory,
134                               Integer explicitPort,
135                               Integer clientTimeout,
136                               Integer serverIdleTimeoutMinutes,
137                               MavenProject mavenProject,
138                               MavenSession mavenSession,
139                               DependencyResolutionService dependencyResolutionService,
140                               ToolchainManager toolchainManager,
141                               String testResourcesVersion,
142                               boolean classpathInference,
143                               List<Dependency> testResourcesDependencies,
144                               String sharedServerNamespace,
145                               boolean debugServer,
146                               boolean foreground, final Map<String, String> testResourcesSystemProperties) {
147        this(mavenSession, enabled, shared, buildDirectory);
148        this.explicitPort = explicitPort;
149        this.clientTimeout = clientTimeout;
150        this.serverIdleTimeoutMinutes = serverIdleTimeoutMinutes;
151        this.mavenProject = mavenProject;
152        this.dependencyResolutionService = dependencyResolutionService;
153        this.toolchainManager = toolchainManager;
154        this.testResourcesVersion = testResourcesVersion;
155        this.classpathInference = classpathInference;
156        this.testResourcesDependencies = testResourcesDependencies;
157        this.sharedServerNamespace = sharedServerNamespace;
158        this.debugServer = debugServer;
159        this.foreground = foreground;
160        this.testResourcesSystemProperties = testResourcesSystemProperties;
161    }
162
163    public TestResourcesHelper(MavenSession mavenSession, boolean enabled, boolean shared, File buildDirectory) {
164        this.mavenSession = mavenSession;
165        this.enabled = enabled;
166        this.shared = shared;
167        this.buildDirectory = buildDirectory;
168        this.log = new SystemStreamLog();
169    }
170
171    private boolean isKeepAlive() {
172        boolean hasKeepAliveFile = Files.isRegularFile(getKeepAliveFile(), LinkOption.NOFOLLOW_LINKS);
173        return hasKeepAliveFile || isStartExplicitlyInvoked();
174    }
175
176    private boolean isStartExplicitlyInvoked() {
177        return mavenSession.getGoals()
178            .stream()
179            .anyMatch(goal -> goal.equals("mn:" + StartTestResourcesServerMojo.NAME));
180    }
181
182    /**
183     * Starts the Test Resources Service.
184     */
185    public void start() throws MojoExecutionException {
186        if (!enabled) {
187            return;
188        }
189        try {
190            doStart();
191        } catch (Exception e) {
192            throw new MojoExecutionException("Unable to start test resources server", e);
193        }
194    }
195
196    private void doStart() throws IOException {
197        var accessToken = UUID.randomUUID().toString();
198        Path buildDir = buildDirectory.toPath();
199        Path serverSettingsDirectory = getServerSettingsDirectory();
200        var serverStarted = new AtomicBoolean(false);
201        var serverFactory = new DefaultServerFactory(log, toolchainManager, mavenSession, serverStarted, testResourcesVersion, debugServer, foreground, !isKeepAlive(), testResourcesSystemProperties);
202        if (shared) {
203            try (var ignored = sharedServerLock(serverSettingsDirectory)) {
204                doStart(accessToken, buildDir, serverSettingsDirectory, serverFactory, serverStarted);
205            }
206            return;
207        }
208        doStart(accessToken, buildDir, serverSettingsDirectory, serverFactory, serverStarted);
209    }
210
211    private void doStart(String accessToken,
212                         Path buildDir,
213                         Path serverSettingsDirectory,
214                         ServerFactory serverFactory,
215                         AtomicBoolean serverStarted) throws IOException {
216        Optional<ServerSettings> sessionSharedServerSettings = findSessionSharedServer(serverSettingsDirectory);
217        if (sessionSharedServerSettings.isPresent()) {
218            ServerSettings serverSettings = sessionSharedServerSettings.get();
219            writePortFile(buildDir.resolve(PORT_FILE_NAME), serverSettings.getPort());
220            registerSharedServerUse(serverSettingsDirectory, serverSettings.getPort(), false);
221            logSharedMode(serverSettingsDirectory);
222            writeSharedScopeConfiguration();
223            setSystemProperties(serverSettings);
224            return;
225        }
226        Optional<ServerSettings> existingServerSettings = findReachableRecordedServer(serverSettingsDirectory);
227        if (existingServerSettings.isPresent()) {
228            useServerSettings(buildDir, serverSettingsDirectory, existingServerSettings.get(), false);
229            return;
230        }
231        Optional<ServerSettings> optionalServerSettings = startOrConnectToExistingServer(accessToken, buildDir, serverSettingsDirectory, serverFactory);
232        if (optionalServerSettings.isEmpty()) {
233            return;
234        }
235        ServerSettings serverSettings = optionalServerSettings.get();
236        useServerSettings(buildDir, serverSettingsDirectory, serverSettings, serverStarted.get());
237        if (serverStarted.get() && isKeepAlive()) {
238            log.info("Micronaut Test Resources service is started in the background. To stop it, run the following command: 'mvn mn:" + StopTestResourcesServerMojo.NAME + "'");
239        }
240    }
241
242    private Optional<ServerSettings> findSessionSharedServer(Path serverSettingsDirectory) {
243        if (!shared || mavenProject == null) {
244            return Optional.empty();
245        }
246        synchronized (SESSION_STATE_MONITOR) {
247            SessionState sessionState = SESSION_STATES.get(mavenSession);
248            if (sessionState == null) {
249                return Optional.empty();
250            }
251            SharedServerState sharedServerState = sessionState.sharedServers.get(normalize(serverSettingsDirectory));
252            if (sharedServerState == null) {
253                return Optional.empty();
254            }
255            return ServerUtils.readServerSettings(serverSettingsDirectory)
256                .filter(settings -> settings.getPort() == sharedServerState.port);
257        }
258    }
259
260    private Optional<ServerSettings> findReachableRecordedServer(Path serverSettingsDirectory) {
261        return ServerUtils.readServerSettings(serverSettingsDirectory)
262            .filter(serverSettings -> explicitPort == null || serverSettings.getPort() == explicitPort)
263            .filter(this::isReusableTestResourcesServer);
264    }
265
266    private boolean isReusableTestResourcesServer(ServerSettings serverSettings) {
267        HttpURLConnection connection = null;
268        try {
269            var url = URI.create("http://localhost:" + serverSettings.getPort() + TEST_RESOURCES_REQUIREMENTS_ENTRIES_PATH).toURL();
270            connection = (HttpURLConnection) url.openConnection();
271            connection.setConnectTimeout(SERVER_PROBE_TIMEOUT_MS);
272            connection.setReadTimeout(SERVER_PROBE_TIMEOUT_MS);
273            connection.setRequestMethod("GET");
274            connection.setRequestProperty("Accept", "application/json");
275            String accessToken = serverSettings.getAccessToken().orElse(null);
276            if (accessToken != null) {
277                connection.setRequestProperty(ACCESS_TOKEN_HEADER, accessToken);
278            }
279            if (connection.getResponseCode() != HttpURLConnection.HTTP_OK || !isJsonContentType(connection.getContentType())) {
280                return false;
281            }
282            try (InputStream input = connection.getInputStream()) {
283                return isJsonStringArray(new String(input.readAllBytes(), StandardCharsets.UTF_8));
284            }
285        } catch (IOException _) {
286            return false;
287        } finally {
288            if (connection != null) {
289                connection.disconnect();
290            }
291        }
292    }
293
294    private static boolean isJsonContentType(String contentType) {
295        if (contentType == null) {
296            return false;
297        }
298        return contentType.equals("application/json") || contentType.startsWith("application/json;");
299    }
300
301    private static boolean isJsonStringArray(String body) {
302        String trimmed = body.trim();
303        return trimmed.length() >= 2 && trimmed.charAt(0) == '[' && trimmed.charAt(trimmed.length() - 1) == ']';
304    }
305
306    private void useServerSettings(Path buildDir, Path serverSettingsDirectory, ServerSettings serverSettings, boolean serverStarted) throws IOException {
307        writePortFile(buildDir.resolve(PORT_FILE_NAME), serverSettings.getPort());
308        boolean sessionOwnedSharedServer = shared && registerSharedServerUse(serverSettingsDirectory, serverSettings.getPort(), serverStarted);
309        if (shared) {
310            logSharedMode(serverSettingsDirectory);
311            writeSharedScopeConfiguration();
312        }
313        setSystemProperties(serverSettings);
314        if (!serverStarted && !sessionOwnedSharedServer) {
315            // A server was already listening before this build started, so leave it running.
316            createKeepAliveFile();
317        }
318    }
319
320    /**
321     * Computes the system properties to set for the test resources client to be able to connect to the server.
322     *
323     * @param serverSettings The server settings
324     * @return The system properties
325     */
326    public Map<String, String> computeSystemProperties(ServerSettings serverSettings) {
327        var systemProperties = new HashMap<String, String>(3);
328        String uri = String.format("http://localhost:%d", serverSettings.getPort());
329        systemProperties.put(TEST_RESOURCES_PROP_SERVER_URI, uri);
330        serverSettings.getAccessToken().ifPresent(accessToken -> systemProperties.put(TEST_RESOURCES_PROP_ACCESS_TOKEN, accessToken));
331        serverSettings.getClientTimeout().ifPresent(timeout -> systemProperties.put(TEST_RESOURCES_PROP_CLIENT_READ_TIMEOUT, String.valueOf(timeout)));
332        return systemProperties;
333    }
334
335    private void setSystemProperties(ServerSettings serverSettings) {
336        computeSystemProperties(serverSettings).forEach(System::setProperty);
337    }
338
339    private static void writePortFile(Path file, int port) throws IOException {
340        Files.createDirectories(file.getParent());
341        Files.writeString(file, Integer.toString(port));
342    }
343
344    private Optional<ServerSettings> startOrConnectToExistingServer(String accessToken, Path buildDir, Path serverSettingsDirectory, ServerFactory serverFactory) {
345        try {
346            return Optional.ofNullable(
347                ServerUtils.startOrConnectToExistingServer(
348                    explicitPort,
349                    buildDir.resolve(PORT_FILE_NAME),
350                    serverSettingsDirectory,
351                    accessToken,
352                    resolveServerClasspath(),
353                    clientTimeout,
354                    serverIdleTimeoutMinutes,
355                    serverFactory
356                )
357            );
358        } catch (Exception e) {
359            log.error("Error starting Micronaut Test Resources service", e);
360            return Optional.empty();
361        }
362    }
363
364    private List<File> resolveServerClasspath() throws DependencyResolutionException {
365        List<MavenDependency> applicationDependencies = Collections.emptyList();
366        if (classpathInference) {
367            applicationDependencies = getApplicationDependencies();
368        }
369        Stream<Artifact> serverDependencies =
370            TestResourcesClasspath.inferTestResourcesClasspath(applicationDependencies, testResourcesVersion)
371                .stream()
372                .map(DependencyResolutionService::testResourcesDependencyToAetherArtifact);
373
374        List<org.apache.maven.model.Dependency> extraDependencies =
375            testResourcesDependencies != null ? testResourcesDependencies : Collections.emptyList();
376
377        Stream<Artifact> extraDependenciesStream = extraDependencies.stream()
378            .map(DependencyResolutionService::mavenDependencyToAetherArtifact);
379
380        Stream<Artifact> artifacts = concat(serverDependencies, extraDependenciesStream);
381
382        var resolutionResult = dependencyResolutionService.artifactResultsFor(artifacts, true);
383        var filteredArtifacts = resolutionResult.stream()
384            .filter(result -> {
385                var artifact = result.getArtifact();
386                var id = new ModuleIdentifier(artifact.getGroupId(), artifact.getArtifactId());
387                return TestResourcesClasspath.isDependencyAllowedOnServerClasspath(id);
388            })
389            .toList();
390        return toClasspathFiles(filteredArtifacts);
391    }
392
393    private List<MavenDependency> getApplicationDependencies() {
394        return this.mavenProject.getDependencies().stream()
395            .map(DependencyResolutionService::mavenDependencyToTestResourcesDependency)
396            .toList();
397    }
398
399    /**
400     * Contains the logic to stop the Test Resources Service.
401     *
402     * @param quiet Whether to perform logging or not.
403     */
404    public void stop(boolean quiet) throws MojoExecutionException {
405        if (!enabled) {
406            return;
407        }
408        if (shared) {
409            try (var ignored = sharedServerLock(getServerSettingsDirectory())) {
410                stopSharedServer(quiet);
411            }
412            return;
413        }
414        stopServer(quiet);
415    }
416
417    private void stopSharedServer(boolean quiet) throws MojoExecutionException {
418        if (releaseSharedServerUse(getServerSettingsDirectory())) {
419            try {
420                cleanupSharedProjectSettings();
421            } catch (IOException e) {
422                throw new MojoExecutionException("Unable to clean shared test resources settings", e);
423            }
424            log("Keeping Micronaut Test Resources service alive for another parallel reactor module", quiet);
425            return;
426        }
427        stopServer(quiet);
428    }
429
430    private void stopServer(boolean quiet) throws MojoExecutionException {
431        if (isKeepAlive()) {
432            log("Keeping Micronaut Test Resources service alive", quiet);
433            return;
434        }
435        try {
436            Optional<ServerSettings> optionalServerSettings = ServerUtils.readServerSettings(getServerSettingsDirectory());
437            if (optionalServerSettings.isPresent()) {
438                if (isServerStarted(optionalServerSettings.get().getPort())) {
439                    log("Shutting down Micronaut Test Resources service", quiet);
440                    doStop();
441                } else {
442                    log("Cannot find Micronaut Test Resources service settings, server may already be shutdown", quiet);
443                    Files.deleteIfExists(getServerSettingsDirectory().resolve(PROPERTIES_FILE_NAME));
444                }
445                cleanupSharedProjectSettings();
446            }
447        } catch (Exception e) {
448            var message = "Unable to stop test resources server";
449            if (quiet) {
450                log.warn(message, e);
451            } else {
452                throw new MojoExecutionException(message, e);
453            }
454        }
455    }
456
457    private void logSharedMode(Path serverSettingsDirectory) throws IOException {
458        if (sharedServerNamespace != null) {
459            log.info("Test Resources is configured in shared mode with the namespace: " + sharedServerNamespace);
460            Path projectSettingsDirectory = serverSettingsDirectoryOf(buildDirectory.toPath());
461            Files.createDirectories(projectSettingsDirectory);
462            Path source = serverSettingsDirectory.resolve(TEST_RESOURCES_PROPERTIES);
463            Path target = projectSettingsDirectory.resolve(TEST_RESOURCES_PROPERTIES);
464            Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
465        } else {
466            log.info("Test Resources is configured in shared mode");
467        }
468    }
469
470    private void writeSharedScopeConfiguration() throws IOException {
471        String scope = sharedScope();
472        if (scope == null) {
473            return;
474        }
475        Path testClassesDirectory = testOutputDirectory(mavenProject, buildDirectory);
476        Files.createDirectories(testClassesDirectory);
477        updateApplicationTestProperties(testClassesDirectory.resolve(APPLICATION_TEST_PROPERTIES), scope);
478        log.info("Using Micronaut Test Resources scope " + scope + " for " + moduleKey());
479    }
480
481    static Path testOutputDirectory(MavenProject mavenProject, File buildDirectory) {
482        if (mavenProject != null && mavenProject.getBuild() != null) {
483            String testOutputDirectory = mavenProject.getBuild().getTestOutputDirectory();
484            if (testOutputDirectory != null && !testOutputDirectory.isBlank()) {
485                return Path.of(testOutputDirectory);
486            }
487        }
488        return buildDirectory.toPath().resolve("test-classes");
489    }
490
491    static void updateApplicationTestProperties(Path file, String scope) throws IOException {
492        Properties properties = new Properties();
493        if (Files.exists(file)) {
494            try (InputStream input = Files.newInputStream(file)) {
495                properties.load(input);
496            }
497        }
498        properties.setProperty(TEST_RESOURCES_SCOPE_PROPERTY, scope);
499        try (OutputStream output = Files.newOutputStream(file)) {
500            properties.store(output, "Generated by micronaut-maven-plugin");
501        }
502    }
503
504    static String sanitizeScopeSegment(String value) {
505        String sanitized = value.replaceAll("[/\\\\]+", ".")
506            .replaceAll("[^A-Za-z0-9_.-]", "-")
507            .replaceAll("[.]{2,}", ".")
508            .replaceAll("-{2,}", "-");
509        sanitized = trimScopeDelimiters(sanitized);
510        return sanitized.isEmpty() ? "root" : sanitized;
511    }
512
513    private static String trimScopeDelimiters(String value) {
514        int start = 0;
515        int end = value.length();
516        while (start < end && isScopeDelimiter(value.charAt(start))) {
517            start++;
518        }
519        while (end > start && isScopeDelimiter(value.charAt(end - 1))) {
520            end--;
521        }
522        return value.substring(start, end);
523    }
524
525    private static boolean isScopeDelimiter(char c) {
526        return c == '.' || c == '-';
527    }
528
529    private String sharedScope() {
530        if (!shared || mavenProject == null) {
531            return null;
532        }
533        Path multiModuleDirectory = mavenSession.getRequest().getMultiModuleProjectDirectory() == null
534            ? null
535            : mavenSession.getRequest().getMultiModuleProjectDirectory().toPath().toAbsolutePath().normalize();
536        Path projectDirectory = mavenProject.getBasedir() == null
537            ? null
538            : mavenProject.getBasedir().toPath().toAbsolutePath().normalize();
539        String projectSegment = mavenProject.getArtifactId();
540        if (multiModuleDirectory != null && projectDirectory != null && projectDirectory.startsWith(multiModuleDirectory)) {
541            Path relativePath = multiModuleDirectory.relativize(projectDirectory);
542            if (relativePath.getNameCount() > 0) {
543                projectSegment = relativePath.toString();
544            }
545        }
546        return sessionState().scopePrefix + "." + sanitizeScopeSegment(projectSegment);
547    }
548
549    private boolean registerSharedServerUse(Path serverSettingsDirectory, int port, boolean serverStarted) {
550        if (mavenProject == null) {
551            return false;
552        }
553        synchronized (SESSION_STATE_MONITOR) {
554            Path key = normalize(serverSettingsDirectory);
555            SessionState sessionState = sessionState();
556            SharedServerState sharedServerState = sessionState.sharedServers.get(key);
557            if (serverStarted) {
558                sharedServerState = new SharedServerState(port);
559                sessionState.sharedServers.put(key, sharedServerState);
560            } else if (sharedServerState == null || sharedServerState.port != port) {
561                return false;
562            }
563            sharedServerState.owners.add(moduleKey());
564            return true;
565        }
566    }
567
568    private boolean releaseSharedServerUse(Path serverSettingsDirectory) {
569        if (mavenProject == null) {
570            return false;
571        }
572        synchronized (SESSION_STATE_MONITOR) {
573            SessionState sessionState = SESSION_STATES.get(mavenSession);
574            if (sessionState == null) {
575                return false;
576            }
577            SharedServerState sharedServerState = sessionState.sharedServers.get(normalize(serverSettingsDirectory));
578            if (sharedServerState == null) {
579                return false;
580            }
581            sharedServerState.owners.remove(moduleKey());
582            if (!sharedServerState.owners.isEmpty()) {
583                return true;
584            }
585            sessionState.sharedServers.remove(normalize(serverSettingsDirectory));
586            return false;
587        }
588    }
589
590    private SessionState sessionState() {
591        synchronized (SESSION_STATE_MONITOR) {
592            return SESSION_STATES.computeIfAbsent(mavenSession, ignored -> new SessionState());
593        }
594    }
595
596    private String moduleKey() {
597        if (mavenProject == null || mavenProject.getBasedir() == null) {
598            return buildDirectory.toPath().toAbsolutePath().normalize().toString();
599        }
600        return mavenProject.getBasedir().toPath().toAbsolutePath().normalize().toString();
601    }
602
603    private static Path normalize(Path path) {
604        return path.toAbsolutePath().normalize();
605    }
606
607    static SharedServerLock sharedServerLock(Path serverSettingsDirectory) {
608        Path key = normalize(serverSettingsDirectory);
609        ReentrantLock lock = SHARED_SERVER_LOCKS.computeIfAbsent(key, ignored -> new ReentrantLock());
610        return new SharedServerLock(key, lock);
611    }
612
613    static int sharedServerLockCount() {
614        return SHARED_SERVER_LOCKS.size();
615    }
616
617    private static void releaseSharedServerLock(Path key, ReentrantLock lock) {
618        lock.unlock();
619        if (!lock.isLocked() && !lock.hasQueuedThreads()) {
620            SHARED_SERVER_LOCKS.remove(key, lock);
621        }
622    }
623
624    private void createKeepAliveFile() throws IOException {
625        Path keepalive = getKeepAliveFile();
626        createKeepAliveDirectory();
627        if (Files.exists(keepalive, LinkOption.NOFOLLOW_LINKS)) {
628            if (!Files.isRegularFile(keepalive, LinkOption.NOFOLLOW_LINKS)) {
629                throw new IOException("Keepalive path exists but is not a regular file: " + keepalive);
630            }
631        } else {
632            Files.writeString(keepalive, "true", StandardOpenOption.CREATE_NEW);
633            Runtime.getRuntime().addShutdownHook(new Thread(() -> {
634                try {
635                    deleteKeepAliveFile();
636                } catch (MojoExecutionException e) {
637                    // ignore, we're in a shutdown hook
638                }
639            }));
640        }
641    }
642
643    private void createKeepAliveDirectory() throws IOException {
644        synchronized (SESSION_STATE_MONITOR) {
645            Path keepAliveDirectory = sessionState().keepAliveDirectory;
646            if (Files.exists(keepAliveDirectory, LinkOption.NOFOLLOW_LINKS)) {
647                if (!Files.isDirectory(keepAliveDirectory, LinkOption.NOFOLLOW_LINKS)) {
648                    throw new IOException("Keepalive directory exists but is not a directory: " + keepAliveDirectory);
649                }
650                return;
651            }
652            try {
653                FileAttribute<?>[] attributes = keepAliveDirectoryAttributes(keepAliveDirectory.getParent());
654                if (attributes.length == 0) {
655                    Files.createDirectory(keepAliveDirectory);
656                } else {
657                    Files.createDirectory(keepAliveDirectory, attributes);
658                }
659            } catch (FileAlreadyExistsException e) {
660                if (!Files.isDirectory(keepAliveDirectory, LinkOption.NOFOLLOW_LINKS)) {
661                    throw new IOException("Keepalive directory exists but is not a directory: " + keepAliveDirectory, e);
662                }
663            }
664        }
665    }
666
667    private static boolean isServerStarted(int port) {
668        if (System.getProperty("test.resources.internal.server.started") != null) {
669            return Boolean.getBoolean("test.resources.internal.server.started");
670        } else {
671            return !SocketUtils.isTcpPortAvailable(port);
672        }
673    }
674
675    private void log(String message, boolean quiet) {
676        if (quiet) {
677            if (log.isDebugEnabled()) {
678                log.debug(message);
679            }
680        } else {
681            log.info(message);
682        }
683    }
684
685    private void doStop() throws IOException, MojoExecutionException {
686        try {
687            Path settingsDirectory = getServerSettingsDirectory();
688            ServerUtils.stopServer(settingsDirectory);
689        } finally {
690            deleteKeepAliveFile();
691        }
692    }
693
694    private void deleteKeepAliveFile() throws MojoExecutionException {
695        if (Files.exists(getKeepAliveFile(), LinkOption.NOFOLLOW_LINKS)) {
696            try {
697                Files.delete(getKeepAliveFile());
698            } catch (IOException e) {
699                throw new MojoExecutionException("Failed to delete keepalive file", e);
700            }
701        }
702        tryDeleteKeepAliveDirectory();
703    }
704
705    private Path getServerSettingsDirectory() {
706        if (shared) {
707            return ServerUtils.getDefaultSharedSettingsPath(sharedServerNamespace);
708        }
709        return serverSettingsDirectoryOf(buildDirectory.toPath());
710    }
711
712    private Path getKeepAliveFile() {
713        return sessionState().keepAliveDirectory.resolve("keepalive-" + mavenSession.getRequest().getBuilderId());
714    }
715
716    private void tryDeleteKeepAliveDirectory() throws MojoExecutionException {
717        Path keepAliveDirectory = sessionState().keepAliveDirectory;
718        try {
719            Files.deleteIfExists(keepAliveDirectory);
720        } catch (DirectoryNotEmptyException ignored) {
721            // Another keepalive file in the same session still owns the directory.
722        } catch (IOException e) {
723            throw new MojoExecutionException("Failed to delete keepalive directory", e);
724        }
725    }
726
727    @SuppressWarnings("unchecked")
728    private static FileAttribute<?>[] keepAliveDirectoryAttributes(Path directory) {
729        try {
730            if (directory == null || !Files.getFileStore(directory).supportsFileAttributeView("posix")) {
731                return new FileAttribute[0];
732            }
733            return new FileAttribute<?>[]{
734                PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))
735            };
736        } catch (IOException | UnsupportedOperationException | SecurityException e) {
737            return new FileAttribute[0];
738        }
739    }
740
741    private void cleanupSharedProjectSettings() throws IOException {
742        if (shared && sharedServerNamespace != null) {
743            Path projectSettingsDirectory = serverSettingsDirectoryOf(buildDirectory.toPath());
744            Files.deleteIfExists(projectSettingsDirectory.resolve(TEST_RESOURCES_PROPERTIES));
745        }
746    }
747
748    private Path serverSettingsDirectoryOf(Path buildDir) {
749        return buildDir.resolve("../.micronaut/test-resources");
750    }
751
752    /**
753     * @param sharedServerNamespace The shared server namespace (if any).
754     */
755    public void setSharedServerNamespace(String sharedServerNamespace) {
756        this.sharedServerNamespace = sharedServerNamespace;
757    }
758
759    private static final class SessionState {
760        private final String scopePrefix = SCOPE_PREFIX + "-" + UUID.randomUUID();
761        private final Path keepAliveDirectory = Path.of(System.getProperty("java.io.tmpdir"))
762            .resolve("mn-test-resources-" + UUID.randomUUID());
763        private final Map<Path, SharedServerState> sharedServers = new LinkedHashMap<>();
764    }
765
766    private static final class SharedServerState {
767        private final int port;
768        private final Set<String> owners = new HashSet<>();
769
770        private SharedServerState(int port) {
771            this.port = port;
772        }
773    }
774
775    static final class SharedServerLock implements AutoCloseable {
776        private final Path key;
777        private final ReentrantLock lock;
778
779        private SharedServerLock(Path key, ReentrantLock lock) {
780            this.key = key;
781            this.lock = lock;
782            this.lock.lock();
783        }
784
785        @Override
786        public void close() {
787            releaseSharedServerLock(key, lock);
788        }
789    }
790}