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