View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.cling.executor.embedded;
20  
21  import java.io.Closeable;
22  import java.io.IOException;
23  import java.io.InputStream;
24  import java.io.PrintStream;
25  import java.lang.reflect.Constructor;
26  import java.lang.reflect.Field;
27  import java.lang.reflect.Method;
28  import java.net.MalformedURLException;
29  import java.net.URL;
30  import java.net.URLClassLoader;
31  import java.nio.file.Files;
32  import java.nio.file.Path;
33  import java.util.ArrayList;
34  import java.util.Collections;
35  import java.util.HashSet;
36  import java.util.List;
37  import java.util.Map;
38  import java.util.Objects;
39  import java.util.Properties;
40  import java.util.Set;
41  import java.util.concurrent.ConcurrentHashMap;
42  import java.util.concurrent.atomic.AtomicBoolean;
43  import java.util.function.Function;
44  import java.util.stream.Stream;
45  
46  import org.apache.maven.api.cli.Executor;
47  import org.apache.maven.api.cli.ExecutorException;
48  import org.apache.maven.api.cli.ExecutorRequest;
49  
50  import static java.util.Objects.requireNonNull;
51  
52  /**
53   * Embedded executor implementation, that invokes Maven from installation directory within this same JVM but in isolated
54   * classloader. This class supports Maven 4.x and Maven 3.x as well. The ClassWorld of Maven is kept in memory as
55   * long as instance of this class is not closed. Subsequent execution requests over same installation home are cached.
56   */
57  public class EmbeddedMavenExecutor implements Executor {
58      protected static final Map<String, String> MAIN_CLASSES =
59              Map.of("mvn", "org.apache.maven.cling.MavenCling", "mvnenc", "org.apache.maven.cling.MavenEncCling");
60  
61      protected static final class Context {
62          private final URLClassLoader bootClassLoader;
63          private final String version;
64          private final Object classWorld;
65          private final Set<String> originalClassRealmIds;
66          private final ClassLoader tccl;
67          private final Function<ExecutorRequest, Integer> exec;
68  
69          private Context(
70                  URLClassLoader bootClassLoader,
71                  String version,
72                  Object classWorld,
73                  Set<String> originalClassRealmIds,
74                  ClassLoader tccl,
75                  Function<ExecutorRequest, Integer> exec) {
76              this.bootClassLoader = bootClassLoader;
77              this.version = version;
78              this.classWorld = classWorld;
79              this.originalClassRealmIds = originalClassRealmIds;
80              this.tccl = tccl;
81              this.exec = exec;
82          }
83      }
84  
85      protected static class Key {
86          private final Path installationDirectory;
87          private final String command;
88  
89          private Key(Path installationDirectory, String command) {
90              this.installationDirectory = installationDirectory;
91              this.command = command;
92          }
93  
94          @Override
95          public boolean equals(Object o) {
96              if (o == null || getClass() != o.getClass()) {
97                  return false;
98              }
99              Key key = (Key) o;
100             return Objects.equals(installationDirectory, key.installationDirectory)
101                     && Objects.equals(command, key.command);
102         }
103 
104         @Override
105         public int hashCode() {
106             return Objects.hash(installationDirectory, command);
107         }
108     }
109 
110     protected final boolean cacheContexts;
111     protected final AtomicBoolean closed;
112     protected final PrintStream originalStdout;
113     protected final PrintStream originalStderr;
114     protected final Properties originalProperties;
115     protected final ClassLoader originalClassLoader;
116     protected final ConcurrentHashMap<Key, Context> contexts;
117 
118     public EmbeddedMavenExecutor() {
119         this(true);
120     }
121 
122     public EmbeddedMavenExecutor(boolean cacheContexts) {
123         this.cacheContexts = cacheContexts;
124         this.closed = new AtomicBoolean(false);
125         this.originalStdout = System.out;
126         this.originalStderr = System.err;
127         this.originalClassLoader = Thread.currentThread().getContextClassLoader();
128         this.contexts = new ConcurrentHashMap<>();
129         this.originalProperties = System.getProperties();
130     }
131 
132     @Override
133     public int execute(ExecutorRequest executorRequest) throws ExecutorException {
134         requireNonNull(executorRequest);
135         if (closed.get()) {
136             throw new ExecutorException("Executor is closed");
137         }
138         validate(executorRequest);
139         Context context = mayCreate(executorRequest);
140 
141         Thread.currentThread().setContextClassLoader(context.tccl);
142         try {
143             if (executorRequest.stdoutConsumer().isPresent()) {
144                 System.setOut(new PrintStream(executorRequest.stdoutConsumer().get(), true));
145             }
146             if (executorRequest.stderrConsumer().isPresent()) {
147                 System.setErr(new PrintStream(executorRequest.stderrConsumer().get(), true));
148             }
149             return context.exec.apply(executorRequest);
150         } catch (Exception e) {
151             throw new ExecutorException("Failed to execute", e);
152         } finally {
153             try {
154                 disposeRuntimeCreatedRealms(context);
155             } finally {
156                 System.setOut(originalStdout);
157                 System.setErr(originalStderr);
158                 Thread.currentThread().setContextClassLoader(originalClassLoader);
159                 System.setProperties(originalProperties);
160                 if (!cacheContexts) {
161                     doClose(context);
162                 }
163             }
164         }
165     }
166 
167     protected void disposeRuntimeCreatedRealms(Context context) {
168         try {
169             Method getRealms = context.classWorld.getClass().getMethod("getRealms");
170             Method disposeRealm = context.classWorld.getClass().getMethod("disposeRealm", String.class);
171             List<Object> realms = (List<Object>) getRealms.invoke(context.classWorld);
172             for (Object realm : realms) {
173                 String realmId = (String) realm.getClass().getMethod("getId").invoke(realm);
174                 if (!context.originalClassRealmIds.contains(realmId)) {
175                     disposeRealm.invoke(context.classWorld, realmId);
176                 }
177             }
178         } catch (Exception e) {
179             throw new ExecutorException("Failed to dispose runtime created realms", e);
180         }
181     }
182 
183     @Override
184     public String mavenVersion(ExecutorRequest executorRequest) throws ExecutorException {
185         requireNonNull(executorRequest);
186         validate(executorRequest);
187         if (closed.get()) {
188             throw new ExecutorException("Executor is closed");
189         }
190         return mayCreate(executorRequest).version;
191     }
192 
193     protected Context mayCreate(ExecutorRequest executorRequest) {
194         Path mavenHome = ExecutorRequest.getCanonicalPath(executorRequest.installationDirectory());
195         String command = executorRequest.command();
196         Key key = new Key(mavenHome, command);
197         if (cacheContexts) {
198             return contexts.computeIfAbsent(key, k -> doCreate(mavenHome, executorRequest));
199         } else {
200             return doCreate(mavenHome, executorRequest);
201         }
202     }
203 
204     protected Context doCreate(Path mavenHome, ExecutorRequest executorRequest) {
205         if (!Files.isDirectory(mavenHome)) {
206             throw new IllegalArgumentException("Installation directory must point to existing directory: " + mavenHome);
207         }
208         if (!MAIN_CLASSES.containsKey(executorRequest.command())) {
209             throw new IllegalArgumentException(
210                     getClass().getSimpleName() + " does not support command " + executorRequest.command());
211         }
212         if (executorRequest.environmentVariables().isPresent()) {
213             throw new IllegalArgumentException(getClass().getSimpleName() + " does not support environment variables");
214         }
215         if (executorRequest.jvmArguments().isPresent()) {
216             throw new IllegalArgumentException(getClass().getSimpleName() + " does not support jvmArguments");
217         }
218         Path boot = mavenHome.resolve("boot");
219         Path m2conf = mavenHome.resolve("bin/m2.conf");
220         if (!Files.isDirectory(boot) || !Files.isRegularFile(m2conf)) {
221             throw new IllegalArgumentException(
222                     "Installation directory does not point to Maven installation: " + mavenHome);
223         }
224 
225         Properties properties = prepareProperties(executorRequest);
226 
227         System.setProperties(properties);
228         URLClassLoader bootClassLoader = createMavenBootClassLoader(boot, Collections.emptyList());
229         Thread.currentThread().setContextClassLoader(bootClassLoader);
230         try {
231             Class<?> launcherClass = bootClassLoader.loadClass("org.codehaus.plexus.classworlds.launcher.Launcher");
232             Object launcher = launcherClass.getDeclaredConstructor().newInstance();
233             Method configure = launcherClass.getMethod("configure", InputStream.class);
234             try (InputStream inputStream = Files.newInputStream(m2conf)) {
235                 configure.invoke(launcher, inputStream);
236             }
237             Object classWorld = launcherClass.getMethod("getWorld").invoke(launcher);
238             Set<String> originalClassRealmIds = new HashSet<>();
239 
240             // collect pre-created (in m2.conf) class realms as "original ones"; the rest are created at runtime
241             Method getRealms = classWorld.getClass().getMethod("getRealms");
242             List<Object> realms = (List<Object>) getRealms.invoke(classWorld);
243             for (Object realm : realms) {
244                 Method realmGetId = realm.getClass().getMethod("getId");
245                 originalClassRealmIds.add((String) realmGetId.invoke(realm));
246             }
247 
248             Class<?> cliClass =
249                     (Class<?>) launcherClass.getMethod("getMainClass").invoke(launcher);
250             String version = getMavenVersion(cliClass);
251             Function<ExecutorRequest, Integer> exec;
252 
253             if (version.startsWith("3.")) {
254                 // 3.x
255                 if (!ExecutorRequest.MVN.equals(executorRequest.command())) {
256                     throw new IllegalArgumentException(getClass().getSimpleName() + "w/ mvn3 does not support command "
257                             + executorRequest.command());
258                 }
259                 Constructor<?> newMavenCli = cliClass.getConstructor(classWorld.getClass());
260                 Object mavenCli = newMavenCli.newInstance(classWorld);
261                 Class<?>[] parameterTypes = {String[].class, String.class, PrintStream.class, PrintStream.class};
262                 Method doMain = cliClass.getMethod("doMain", parameterTypes);
263                 exec = r -> {
264                     System.setProperties(null);
265                     System.setProperties(prepareProperties(r));
266                     try {
267                         return (int) doMain.invoke(mavenCli, new Object[] {
268                             r.arguments().toArray(new String[0]), r.cwd().toString(), null, null
269                         });
270                     } catch (Exception e) {
271                         throw new ExecutorException("Failed to execute", e);
272                     }
273                 };
274             } else {
275                 // assume 4.x
276                 Method mainMethod = cliClass.getMethod("main", String[].class, classWorld.getClass());
277                 Class<?> ansiConsole = cliClass.getClassLoader().loadClass("org.jline.jansi.AnsiConsole");
278                 Field ansiConsoleInstalled = ansiConsole.getDeclaredField("installed");
279                 ansiConsoleInstalled.setAccessible(true);
280                 exec = r -> {
281                     System.setProperties(null);
282                     System.setProperties(prepareProperties(r));
283                     try {
284                         try {
285                             if (r.stdoutConsumer().isPresent()
286                                     || r.stderrConsumer().isPresent()) {
287                                 ansiConsoleInstalled.set(null, 1);
288                             }
289                             return (int) mainMethod.invoke(null, r.arguments().toArray(new String[0]), classWorld);
290                         } finally {
291                             if (r.stdoutConsumer().isPresent()
292                                     || r.stderrConsumer().isPresent()) {
293                                 ansiConsoleInstalled.set(null, 0);
294                             }
295                         }
296                     } catch (Exception e) {
297                         throw new ExecutorException("Failed to execute", e);
298                     }
299                 };
300             }
301 
302             return new Context(
303                     bootClassLoader, version, classWorld, originalClassRealmIds, cliClass.getClassLoader(), exec);
304         } catch (Exception e) {
305             throw new ExecutorException("Failed to create executor", e);
306         } finally {
307             Thread.currentThread().setContextClassLoader(originalClassLoader);
308             System.setProperties(originalProperties);
309         }
310     }
311 
312     protected Properties prepareProperties(ExecutorRequest request) {
313         Properties properties = new Properties();
314         properties.putAll(System.getProperties());
315 
316         properties.setProperty("user.dir", request.cwd().toString());
317         properties.setProperty("user.home", request.userHomeDirectory().toString());
318 
319         Path mavenHome = request.installationDirectory();
320         properties.setProperty("maven.home", mavenHome.toString());
321         properties.setProperty(
322                 "maven.multiModuleProjectDirectory", request.cwd().toString());
323         String mainClass = requireNonNull(MAIN_CLASSES.get(request.command()), "mainClass");
324         properties.setProperty("maven.mainClass", mainClass);
325         properties.setProperty(
326                 "library.jline.path", mavenHome.resolve("lib/jline-native").toString());
327         // TODO: is this needed?
328         properties.setProperty("org.jline.terminal.provider", "dumb");
329 
330         if (request.jvmSystemProperties().isPresent()) {
331             properties.putAll(request.jvmSystemProperties().get());
332         }
333 
334         return properties;
335     }
336 
337     @Override
338     public void close() throws ExecutorException {
339         if (closed.compareAndExchange(false, true)) {
340             try {
341                 ArrayList<Exception> exceptions = new ArrayList<>();
342                 for (Context context : contexts.values()) {
343                     try {
344                         doClose(context);
345                     } catch (Exception e) {
346                         exceptions.add(e);
347                     }
348                 }
349                 if (!exceptions.isEmpty()) {
350                     ExecutorException e = new ExecutorException("Could not close cleanly");
351                     exceptions.forEach(e::addSuppressed);
352                     throw e;
353                 }
354             } finally {
355                 System.setProperties(originalProperties);
356             }
357         }
358     }
359 
360     protected void doClose(Context context) throws ExecutorException {
361         Thread.currentThread().setContextClassLoader(context.bootClassLoader);
362         try {
363             try {
364                 ((Closeable) context.classWorld).close();
365             } finally {
366                 context.bootClassLoader.close();
367             }
368         } catch (Exception e) {
369             throw new ExecutorException("Failed to close cleanly", e);
370         } finally {
371             Thread.currentThread().setContextClassLoader(originalClassLoader);
372         }
373     }
374 
375     protected void validate(ExecutorRequest executorRequest) throws ExecutorException {}
376 
377     protected URLClassLoader createMavenBootClassLoader(Path boot, List<URL> extraClasspath) {
378         ArrayList<URL> urls = new ArrayList<>(extraClasspath);
379         try (Stream<Path> stream = Files.list(boot)) {
380             stream.filter(Files::isRegularFile)
381                     .filter(p -> p.toString().endsWith(".jar"))
382                     .forEach(f -> {
383                         try {
384                             urls.add(f.toUri().toURL());
385                         } catch (MalformedURLException e) {
386                             throw new ExecutorException("Failed to build classpath: " + f, e);
387                         }
388                     });
389         } catch (IOException e) {
390             throw new ExecutorException("Failed to build classpath: " + e, e);
391         }
392         if (urls.isEmpty()) {
393             throw new IllegalArgumentException("Invalid Maven home directory; boot is empty");
394         }
395         return new URLClassLoader(
396                 urls.toArray(new URL[0]), ClassLoader.getSystemClassLoader().getParent());
397     }
398 
399     protected String getMavenVersion(Class<?> clazz) throws IOException {
400         Properties props = new Properties();
401         try (InputStream is = clazz.getResourceAsStream("/META-INF/maven/org.apache.maven/maven-core/pom.properties")) {
402             if (is != null) {
403                 props.load(is);
404             }
405             String version = props.getProperty("version");
406             if (version != null) {
407                 return version;
408             }
409             return UNKNOWN_VERSION;
410         }
411     }
412 }