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.io.IOException;
038import java.nio.file.Files;
039import java.nio.file.Path;
040import java.nio.file.StandardCopyOption;
041import java.util.Collections;
042import java.util.HashMap;
043import java.util.List;
044import java.util.Map;
045import java.util.Optional;
046import java.util.UUID;
047import java.util.concurrent.atomic.AtomicBoolean;
048import java.util.stream.Stream;
049
050import static io.micronaut.maven.services.DependencyResolutionService.toClasspathFiles;
051import static io.micronaut.testresources.buildtools.ServerUtils.PROPERTIES_FILE_NAME;
052import static java.util.stream.Stream.concat;
053
054/**
055 * Utility class to stop Test Resources service.
056 */
057public class TestResourcesHelper {
058
059    private static final String TEST_RESOURCES_PROPERTIES = "test-resources.properties";
060    private static final String PORT_FILE_NAME = "test-resources-port.txt";
061
062    private static final String TEST_RESOURCES_CLIENT_SYSTEM_PROP_PREFIX = "micronaut.test.resources.";
063
064    private static final String TEST_RESOURCES_PROP_SERVER_URI = TEST_RESOURCES_CLIENT_SYSTEM_PROP_PREFIX + "server.uri";
065    private static final String TEST_RESOURCES_PROP_ACCESS_TOKEN = TEST_RESOURCES_CLIENT_SYSTEM_PROP_PREFIX + "server.access.token";
066    private static final String TEST_RESOURCES_PROP_CLIENT_READ_TIMEOUT = TEST_RESOURCES_CLIENT_SYSTEM_PROP_PREFIX + "server.client.read.timeout";
067
068    private final boolean enabled;
069
070    private final MavenSession mavenSession;
071
072    private final boolean shared;
073
074    private final File buildDirectory;
075
076    private final Log log;
077
078    private Integer explicitPort;
079
080    private Integer clientTimeout;
081
082    private Integer serverIdleTimeoutMinutes;
083
084    private MavenProject mavenProject;
085
086    private DependencyResolutionService dependencyResolutionService;
087
088    private ToolchainManager toolchainManager;
089
090    private String testResourcesVersion;
091
092    private boolean classpathInference;
093
094    private List<Dependency> testResourcesDependencies;
095
096    private String sharedServerNamespace;
097
098    private boolean debugServer;
099
100    private boolean foreground = false;
101
102    public TestResourcesHelper(boolean enabled,
103                               boolean shared,
104                               File buildDirectory,
105                               Integer explicitPort,
106                               Integer clientTimeout,
107                               Integer serverIdleTimeoutMinutes,
108                               MavenProject mavenProject,
109                               MavenSession mavenSession,
110                               DependencyResolutionService dependencyResolutionService,
111                               ToolchainManager toolchainManager,
112                               String testResourcesVersion,
113                               boolean classpathInference,
114                               List<Dependency> testResourcesDependencies,
115                               String sharedServerNamespace,
116                               boolean debugServer,
117                               boolean foreground) {
118        this(mavenSession, enabled, shared, buildDirectory);
119        this.explicitPort = explicitPort;
120        this.clientTimeout = clientTimeout;
121        this.serverIdleTimeoutMinutes = serverIdleTimeoutMinutes;
122        this.mavenProject = mavenProject;
123        this.dependencyResolutionService = dependencyResolutionService;
124        this.toolchainManager = toolchainManager;
125        this.testResourcesVersion = testResourcesVersion;
126        this.classpathInference = classpathInference;
127        this.testResourcesDependencies = testResourcesDependencies;
128        this.sharedServerNamespace = sharedServerNamespace;
129        this.debugServer = debugServer;
130        this.foreground = foreground;
131    }
132
133    public TestResourcesHelper(MavenSession mavenSession, boolean enabled, boolean shared, File buildDirectory) {
134        this.mavenSession = mavenSession;
135        this.enabled = enabled;
136        this.shared = shared;
137        this.buildDirectory = buildDirectory;
138        this.log = new SystemStreamLog();
139    }
140
141    private boolean isKeepAlive() {
142        boolean hasKeepAliveFile = Files.exists(getKeepAliveFile());
143        return hasKeepAliveFile || isStartExplicitlyInvoked();
144    }
145
146    private boolean isStartExplicitlyInvoked() {
147        return mavenSession.getGoals()
148            .stream()
149            .anyMatch(goal -> goal.equals("mn:" + StartTestResourcesServerMojo.NAME));
150    }
151
152    /**
153     * Starts the Test Resources Service.
154     */
155    public void start() throws MojoExecutionException {
156        if (!enabled) {
157            return;
158        }
159        try {
160            doStart();
161        } catch (Exception e) {
162            throw new MojoExecutionException("Unable to start test resources server", e);
163        }
164    }
165
166    private void doStart() throws IOException {
167        var accessToken = UUID.randomUUID().toString();
168        Path buildDir = buildDirectory.toPath();
169        Path serverSettingsDirectory = getServerSettingsDirectory();
170        var serverStarted = new AtomicBoolean(false);
171        var serverFactory = new DefaultServerFactory(log, toolchainManager, mavenSession, serverStarted, testResourcesVersion, debugServer, foreground);
172        Optional<ServerSettings> optionalServerSettings = startOrConnectToExistingServer(accessToken, buildDir, serverSettingsDirectory, serverFactory);
173        if (optionalServerSettings.isPresent()) {
174            ServerSettings serverSettings = optionalServerSettings.get();
175            if (shared) {
176                if (sharedServerNamespace != null) {
177                    log.info("Test Resources is configured in shared mode with the namespace: " + sharedServerNamespace);
178                    //Copy the server settings to the default location so that TR Client can find it
179                    Path projectSettingsDirectory = serverSettingsDirectoryOf(buildDirectory.toPath());
180                    Files.createDirectories(projectSettingsDirectory);
181
182                    Path source = serverSettingsDirectory.resolve(TEST_RESOURCES_PROPERTIES);
183                    Path target = projectSettingsDirectory.resolve(TEST_RESOURCES_PROPERTIES);
184                    Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
185
186                } else {
187                    log.info("Test Resources is configured in shared mode");
188                }
189            }
190            setSystemProperties(serverSettings);
191            if (serverStarted.get()) {
192                if (isKeepAlive()) {
193                    log.info("Micronaut Test Resources service is started in the background. To stop it, run the following command: 'mvn mn:" + StopTestResourcesServerMojo.NAME + "'");
194                }
195            } else {
196                // A server was already listening, which means it was running before
197                // the build was started, so we put a file to indicate to the stop
198                // mojo that it should not stop the server.
199                Path keepalive = getKeepAliveFile();
200                // Test is because we may be running in watch mode
201                if (!Files.exists(keepalive)) {
202                    Files.write(keepalive, "true".getBytes());
203                    Runtime.getRuntime().addShutdownHook(new Thread(() -> {
204                        // Make sure that if the build is interrupted, e.g using CTRL+C, the keepalive file is deleted
205                        try {
206                            deleteKeepAliveFile();
207                        } catch (MojoExecutionException e) {
208                            // ignore, we're in a shutdown hook
209                        }
210                    }));
211                }
212            }
213        }
214    }
215
216    /**
217     * Computes the system properties to set for the test resources client to be able to connect to the server.
218     *
219     * @param serverSettings The server settings
220     * @return The system properties
221     */
222    public Map<String, String> computeSystemProperties(ServerSettings serverSettings) {
223        var systemProperties = new HashMap<String, String>(3);
224        String uri = String.format("http://localhost:%d", serverSettings.getPort());
225        systemProperties.put(TEST_RESOURCES_PROP_SERVER_URI, uri);
226        serverSettings.getAccessToken().ifPresent(accessToken -> systemProperties.put(TEST_RESOURCES_PROP_ACCESS_TOKEN, accessToken));
227        serverSettings.getClientTimeout().ifPresent(timeout -> systemProperties.put(TEST_RESOURCES_PROP_CLIENT_READ_TIMEOUT, String.valueOf(timeout)));
228        return systemProperties;
229    }
230
231    private void setSystemProperties(ServerSettings serverSettings) {
232        computeSystemProperties(serverSettings).forEach(System::setProperty);
233    }
234
235    private Optional<ServerSettings> startOrConnectToExistingServer(String accessToken, Path buildDir, Path serverSettingsDirectory, ServerFactory serverFactory) {
236        try {
237            return Optional.ofNullable(
238                ServerUtils.startOrConnectToExistingServer(
239                    explicitPort,
240                    buildDir.resolve(PORT_FILE_NAME),
241                    serverSettingsDirectory,
242                    accessToken,
243                    resolveServerClasspath(),
244                    clientTimeout,
245                    serverIdleTimeoutMinutes,
246                    serverFactory
247                )
248            );
249        } catch (Exception e) {
250            log.error("Error starting Micronaut Test Resources service", e);
251            return Optional.empty();
252        }
253    }
254
255    private List<File> resolveServerClasspath() throws DependencyResolutionException {
256        List<MavenDependency> applicationDependencies = Collections.emptyList();
257        if (classpathInference) {
258            applicationDependencies = getApplicationDependencies();
259        }
260        Stream<Artifact> serverDependencies =
261            TestResourcesClasspath.inferTestResourcesClasspath(applicationDependencies, testResourcesVersion)
262                .stream()
263                .map(DependencyResolutionService::testResourcesDependencyToAetherArtifact);
264
265        List<org.apache.maven.model.Dependency> extraDependencies =
266            testResourcesDependencies != null ? testResourcesDependencies : Collections.emptyList();
267
268        Stream<Artifact> extraDependenciesStream = extraDependencies.stream()
269            .map(DependencyResolutionService::mavenDependencyToAetherArtifact);
270
271        Stream<Artifact> artifacts = concat(serverDependencies, extraDependenciesStream);
272
273        var resolutionResult = dependencyResolutionService.artifactResultsFor(artifacts, true);
274        var filteredArtifacts = resolutionResult.stream()
275            .filter(result -> {
276                var artifact = result.getArtifact();
277                var id = new ModuleIdentifier(artifact.getGroupId(), artifact.getArtifactId());
278                return TestResourcesClasspath.isDependencyAllowedOnServerClasspath(id);
279            })
280            .toList();
281        return toClasspathFiles(filteredArtifacts);
282    }
283
284    private List<MavenDependency> getApplicationDependencies() {
285        return this.mavenProject.getDependencies().stream()
286            .map(DependencyResolutionService::mavenDependencyToTestResourcesDependency)
287            .toList();
288    }
289
290    /**
291     * Contains the logic to stop the Test Resources Service.
292     *
293     * @param quiet Whether to perform logging or not.
294     */
295    public void stop(boolean quiet) throws MojoExecutionException {
296        if (!enabled) {
297            return;
298        }
299        if (isKeepAlive()) {
300            log("Keeping Micronaut Test Resources service alive", quiet);
301            return;
302        }
303        try {
304            Optional<ServerSettings> optionalServerSettings = ServerUtils.readServerSettings(getServerSettingsDirectory());
305            if (optionalServerSettings.isPresent()) {
306                if (isServerStarted(optionalServerSettings.get().getPort())) {
307                    log("Shutting down Micronaut Test Resources service", quiet);
308                    doStop();
309                } else {
310                    log("Cannot find Micronaut Test Resources service settings, server may already be shutdown", quiet);
311                    Files.deleteIfExists(getServerSettingsDirectory().resolve(PROPERTIES_FILE_NAME));
312                }
313                if (shared && sharedServerNamespace != null) {
314                    Path projectSettingsDirectory = serverSettingsDirectoryOf(buildDirectory.toPath());
315                    Files.deleteIfExists(projectSettingsDirectory.resolve(TEST_RESOURCES_PROPERTIES));
316                }
317            }
318        } catch (Exception e) {
319            throw new MojoExecutionException("Unable to stop test resources server", e);
320        }
321    }
322
323    private static boolean isServerStarted(int port) {
324        if (System.getProperty("test.resources.internal.server.started") != null) {
325            return Boolean.getBoolean("test.resources.internal.server.started");
326        } else {
327            return !SocketUtils.isTcpPortAvailable(port);
328        }
329    }
330
331    private void log(String message, boolean quiet) {
332        if (quiet) {
333            if (log.isDebugEnabled()) {
334                log.debug(message);
335            }
336        } else {
337            log.info(message);
338        }
339    }
340
341    private void doStop() throws IOException, MojoExecutionException {
342        try {
343            Path settingsDirectory = getServerSettingsDirectory();
344            ServerUtils.stopServer(settingsDirectory);
345        } finally {
346            deleteKeepAliveFile();
347        }
348    }
349
350    private void deleteKeepAliveFile() throws MojoExecutionException {
351        if (Files.exists(getKeepAliveFile())) {
352            try {
353                Files.delete(getKeepAliveFile());
354            } catch (IOException e) {
355                throw new MojoExecutionException("Failed to delete keepalive file", e);
356            }
357        }
358    }
359
360    private Path getServerSettingsDirectory() {
361        if (shared) {
362            return ServerUtils.getDefaultSharedSettingsPath(sharedServerNamespace);
363        }
364        return serverSettingsDirectoryOf(buildDirectory.toPath());
365    }
366
367    private Path getKeepAliveFile() {
368        var tmpDir = Path.of(System.getProperty("java.io.tmpdir"));
369        return tmpDir.resolve("keepalive-" + mavenSession.getRequest().getBuilderId());
370    }
371
372    private Path serverSettingsDirectoryOf(Path buildDir) {
373        return buildDir.resolve("../.micronaut/test-resources");
374    }
375
376    /**
377     * @param sharedServerNamespace The shared server namespace (if any).
378     */
379    public void setSharedServerNamespace(String sharedServerNamespace) {
380        this.sharedServerNamespace = sharedServerNamespace;
381    }
382}