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.OutputStream;
25  import java.io.PrintStream;
26  import java.lang.reflect.Constructor;
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.Arrays;
35  import java.util.Collection;
36  import java.util.Collections;
37  import java.util.HashMap;
38  import java.util.HashSet;
39  import java.util.List;
40  import java.util.Map;
41  import java.util.Properties;
42  import java.util.Set;
43  import java.util.concurrent.ConcurrentHashMap;
44  import java.util.concurrent.atomic.AtomicBoolean;
45  import java.util.function.Function;
46  import java.util.stream.Stream;
47  
48  import org.apache.maven.api.cli.Executor;
49  import org.apache.maven.api.cli.ExecutorException;
50  import org.apache.maven.api.cli.ExecutorRequest;
51  
52  import static java.util.Objects.requireNonNull;
53  
54  /**
55   * Embedded executor implementation, that invokes Maven from installation directory within this same JVM but in isolated
56   * classloader. This class supports Maven 4.x and Maven 3.x as well. The ClassWorld of Maven is kept in memory as
57   * long as instance of this class is not closed. Subsequent execution requests over same installation home are cached.
58   */
59  public class EmbeddedMavenExecutor implements Executor {
60      /**
61       * Maven4 supports multiple commands from same installation directory.
62       */
63      protected static final Map<String, String> MVN4_MAIN_CLASSES = Map.of(
64              "mvn",
65              "org.apache.maven.cling.MavenCling",
66              "mvnenc",
67              "org.apache.maven.cling.MavenEncCling",
68              "mvnsh",
69              "org.apache.maven.cling.MavenShellCling");
70  
71      /**
72       * Context holds things loaded up from given Maven Installation Directory.
73       */
74      protected static final class Context {
75          private final URLClassLoader bootClassLoader;
76          private final String version;
77          private final Object classWorld;
78          private final Set<String> originalClassRealmIds;
79          private final ClassLoader tccl;
80          private final Map<String, Function<ExecutorRequest, Integer>> commands; // the commands
81          private final Collection<Object> keepAlive; // refs things to make sure no GC takes it away
82  
83          private Context(
84                  URLClassLoader bootClassLoader,
85                  String version,
86                  Object classWorld,
87                  Set<String> originalClassRealmIds,
88                  ClassLoader tccl,
89                  Map<String, Function<ExecutorRequest, Integer>> commands,
90                  Collection<Object> keepAlive) {
91              this.bootClassLoader = bootClassLoader;
92              this.version = version;
93              this.classWorld = classWorld;
94              this.originalClassRealmIds = originalClassRealmIds;
95              this.tccl = tccl;
96              this.commands = commands;
97              this.keepAlive = keepAlive;
98          }
99      }
100 
101     protected final boolean cacheContexts;
102     protected final boolean useMavenArgsEnv;
103     protected final AtomicBoolean closed;
104     protected final InputStream originalStdin;
105     protected final PrintStream originalStdout;
106     protected final PrintStream originalStderr;
107     protected final Properties originalProperties;
108     protected final ClassLoader originalClassLoader;
109     protected final ConcurrentHashMap<Path, Context> contexts;
110 
111     public EmbeddedMavenExecutor() {
112         this(true, true);
113     }
114 
115     public EmbeddedMavenExecutor(boolean cacheContexts, boolean useMavenArgsEnv) {
116         this.cacheContexts = cacheContexts;
117         this.useMavenArgsEnv = useMavenArgsEnv;
118         this.closed = new AtomicBoolean(false);
119         this.originalStdin = System.in;
120         this.originalStdout = System.out;
121         this.originalStderr = System.err;
122         this.originalClassLoader = Thread.currentThread().getContextClassLoader();
123         this.contexts = new ConcurrentHashMap<>();
124         this.originalProperties = new Properties();
125         this.originalProperties.putAll(System.getProperties());
126     }
127 
128     @Override
129     public int execute(ExecutorRequest executorRequest) throws ExecutorException {
130         requireNonNull(executorRequest);
131         if (closed.get()) {
132             throw new ExecutorException("Executor is closed");
133         }
134         validate(executorRequest);
135         Context context = mayCreate(executorRequest);
136         String command = executorRequest.command();
137         Function<ExecutorRequest, Integer> exec = context.commands.get(command);
138         if (exec == null) {
139             throw new IllegalArgumentException(
140                     "Unknown command: '" + command + "' for '" + executorRequest.installationDirectory() + "'");
141         }
142 
143         Thread.currentThread().setContextClassLoader(context.tccl);
144         try {
145             return exec.apply(executorRequest);
146         } catch (Exception e) {
147             throw new ExecutorException("Failed to execute", e);
148         } finally {
149             try {
150                 disposeRuntimeCreatedRealms(context);
151             } finally {
152                 System.setIn(originalStdin);
153                 System.setOut(originalStdout);
154                 System.setErr(originalStderr);
155                 Thread.currentThread().setContextClassLoader(originalClassLoader);
156                 System.setProperties(originalProperties);
157                 if (!cacheContexts) {
158                     doClose(context);
159                 }
160             }
161         }
162     }
163 
164     /**
165      * Unloads dynamically loaded things, like extensions created realms. Makes sure we go back to "initial state".
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         if (cacheContexts) {
196             return contexts.computeIfAbsent(mavenHome, k -> doCreate(mavenHome, executorRequest));
197         } else {
198             return doCreate(mavenHome, executorRequest);
199         }
200     }
201 
202     protected Context doCreate(Path mavenHome, ExecutorRequest executorRequest) {
203         if (!Files.isDirectory(mavenHome)) {
204             throw new IllegalArgumentException("Installation directory must point to existing directory: " + mavenHome);
205         }
206         if (!MVN4_MAIN_CLASSES.containsKey(executorRequest.command())) {
207             throw new IllegalArgumentException(
208                     getClass().getSimpleName() + " does not support command " + executorRequest.command());
209         }
210         if (executorRequest.environmentVariables().isPresent()) {
211             throw new IllegalArgumentException(getClass().getSimpleName() + " does not support environment variables");
212         }
213         if (executorRequest.jvmArguments().isPresent()) {
214             throw new IllegalArgumentException(getClass().getSimpleName() + " does not support jvmArguments");
215         }
216         Path boot = mavenHome.resolve("boot");
217         Path m2conf = mavenHome.resolve("bin/m2.conf");
218         if (!Files.isDirectory(boot) || !Files.isRegularFile(m2conf)) {
219             throw new IllegalArgumentException(
220                     "Installation directory does not point to Maven installation: " + mavenHome);
221         }
222 
223         ArrayList<String> mavenArgs = new ArrayList<>();
224         String mavenArgsEnv = System.getenv("MAVEN_ARGS");
225         if (useMavenArgsEnv && mavenArgsEnv != null && !mavenArgsEnv.isEmpty()) {
226             Arrays.stream(mavenArgsEnv.split(" "))
227                     .filter(s -> !s.trim().isEmpty())
228                     .forEach(s -> mavenArgs.add(0, s));
229         }
230 
231         Properties properties = prepareProperties(executorRequest);
232         // set ahead of time, if the mavenHome points to Maven4, as ClassWorld Launcher needs this property
233         properties.setProperty(
234                 "maven.mainClass", requireNonNull(MVN4_MAIN_CLASSES.get(ExecutorRequest.MVN), "mainClass"));
235         System.setProperties(properties);
236         URLClassLoader bootClassLoader = createMavenBootClassLoader(boot, Collections.emptyList());
237         Thread.currentThread().setContextClassLoader(bootClassLoader);
238         try {
239             Class<?> launcherClass = bootClassLoader.loadClass("org.codehaus.plexus.classworlds.launcher.Launcher");
240             Object launcher = launcherClass.getDeclaredConstructor().newInstance();
241             Method configure = launcherClass.getMethod("configure", InputStream.class);
242             try (InputStream inputStream = Files.newInputStream(m2conf)) {
243                 configure.invoke(launcher, inputStream);
244             }
245             Object classWorld = launcherClass.getMethod("getWorld").invoke(launcher);
246             Set<String> originalClassRealmIds = new HashSet<>();
247 
248             // collect pre-created (in m2.conf) class realms as "original ones"; the rest are created at runtime
249             Method getRealms = classWorld.getClass().getMethod("getRealms");
250             List<Object> realms = (List<Object>) getRealms.invoke(classWorld);
251             for (Object realm : realms) {
252                 Method realmGetId = realm.getClass().getMethod("getId");
253                 originalClassRealmIds.add((String) realmGetId.invoke(realm));
254             }
255 
256             Class<?> cliClass =
257                     (Class<?>) launcherClass.getMethod("getMainClass").invoke(launcher);
258             String version = getMavenVersion(cliClass);
259             Map<String, Function<ExecutorRequest, Integer>> commands = new HashMap<>();
260             ArrayList<Object> keepAlive = new ArrayList<>();
261 
262             if (version.startsWith("3.")) {
263                 // 3.x
264                 if (!ExecutorRequest.MVN.equals(executorRequest.command())) {
265                     throw new IllegalArgumentException(getClass().getSimpleName() + " w/ mvn3 does not support command "
266                             + executorRequest.command());
267                 }
268                 keepAlive.add(cliClass.getClassLoader().loadClass("org.fusesource.jansi.internal.JansiLoader"));
269                 Constructor<?> newMavenCli = cliClass.getConstructor(classWorld.getClass());
270                 Object mavenCli = newMavenCli.newInstance(classWorld);
271                 Class<?>[] parameterTypes = {String[].class, String.class, PrintStream.class, PrintStream.class};
272                 Method doMain = cliClass.getMethod("doMain", parameterTypes);
273                 commands.put(ExecutorRequest.MVN, r -> {
274                     System.setProperties(prepareProperties(r));
275                     try {
276                         ArrayList<String> args = new ArrayList<>(mavenArgs);
277                         args.addAll(r.arguments());
278                         PrintStream stdout = r.stdOut().isEmpty()
279                                 ? null
280                                 : new PrintStream(r.stdOut().orElseThrow(), true);
281                         PrintStream stderr = r.stdErr().isEmpty()
282                                 ? null
283                                 : new PrintStream(r.stdErr().orElseThrow(), true);
284                         return (int) doMain.invoke(mavenCli, new Object[] {
285                             args.toArray(new String[0]), r.cwd().toString(), stdout, stderr
286                         });
287                     } catch (Exception e) {
288                         throw new ExecutorException("Failed to execute", e);
289                     }
290                 });
291             } else {
292                 // assume 4.x
293                 keepAlive.add(cliClass.getClassLoader().loadClass("org.jline.nativ.JLineNativeLoader"));
294                 for (Map.Entry<String, String> cmdEntry : MVN4_MAIN_CLASSES.entrySet()) {
295                     Class<?> cmdClass = cliClass.getClassLoader().loadClass(cmdEntry.getValue());
296                     Method mainMethod = cmdClass.getMethod(
297                             "main",
298                             String[].class,
299                             classWorld.getClass(),
300                             InputStream.class,
301                             OutputStream.class,
302                             OutputStream.class);
303                     commands.put(cmdEntry.getKey(), r -> {
304                         System.setProperties(prepareProperties(r));
305                         try {
306                             ArrayList<String> args = new ArrayList<>(mavenArgs);
307                             args.addAll(r.arguments());
308                             return (int) mainMethod.invoke(
309                                     null,
310                                     args.toArray(new String[0]),
311                                     classWorld,
312                                     r.stdIn().orElse(null),
313                                     r.stdOut().orElse(null),
314                                     r.stdErr().orElse(null));
315                         } catch (Exception e) {
316                             throw new ExecutorException("Failed to execute", e);
317                         }
318                     });
319                 }
320             }
321 
322             return new Context(
323                     bootClassLoader,
324                     version,
325                     classWorld,
326                     originalClassRealmIds,
327                     cliClass.getClassLoader(),
328                     commands,
329                     keepAlive);
330         } catch (Exception e) {
331             throw new ExecutorException("Failed to create executor", e);
332         } finally {
333             Thread.currentThread().setContextClassLoader(originalClassLoader);
334             System.setProperties(originalProperties);
335         }
336     }
337 
338     protected Properties prepareProperties(ExecutorRequest request) {
339         System.setProperties(null); // this "inits" them!
340 
341         Properties properties = new Properties();
342         properties.putAll(System.getProperties()); // get mandatory/expected init-ed above
343 
344         properties.setProperty("user.dir", request.cwd().toString());
345         properties.setProperty("user.home", request.userHomeDirectory().toString());
346 
347         Path mavenHome = request.installationDirectory();
348         properties.setProperty("maven.home", mavenHome.toString());
349         properties.setProperty(
350                 "maven.multiModuleProjectDirectory", request.cwd().toString());
351 
352         // Maven 3.x
353         properties.setProperty(
354                 "library.jansi.path", mavenHome.resolve("lib/jansi-native").toString());
355 
356         // Maven 4.x
357         properties.setProperty(
358                 "library.jline.path", mavenHome.resolve("lib/jline-native").toString());
359 
360         if (request.jvmSystemProperties().isPresent()) {
361             properties.putAll(request.jvmSystemProperties().get());
362         }
363 
364         return properties;
365     }
366 
367     @Override
368     public void close() throws ExecutorException {
369         if (closed.compareAndExchange(false, true)) {
370             try {
371                 ArrayList<Exception> exceptions = new ArrayList<>();
372                 for (Context context : contexts.values()) {
373                     try {
374                         doClose(context);
375                     } catch (Exception e) {
376                         exceptions.add(e);
377                     }
378                 }
379                 if (!exceptions.isEmpty()) {
380                     ExecutorException e = new ExecutorException("Could not close cleanly");
381                     exceptions.forEach(e::addSuppressed);
382                     throw e;
383                 }
384             } finally {
385                 System.setProperties(originalProperties);
386             }
387         }
388     }
389 
390     protected void doClose(Context context) throws ExecutorException {
391         Thread.currentThread().setContextClassLoader(context.bootClassLoader);
392         try {
393             try {
394                 ((Closeable) context.classWorld).close();
395             } finally {
396                 context.bootClassLoader.close();
397             }
398         } catch (Exception e) {
399             throw new ExecutorException("Failed to close cleanly", e);
400         } finally {
401             Thread.currentThread().setContextClassLoader(originalClassLoader);
402         }
403     }
404 
405     protected void validate(ExecutorRequest executorRequest) throws ExecutorException {}
406 
407     protected URLClassLoader createMavenBootClassLoader(Path boot, List<URL> extraClasspath) {
408         ArrayList<URL> urls = new ArrayList<>(extraClasspath);
409         try (Stream<Path> stream = Files.list(boot)) {
410             stream.filter(Files::isRegularFile)
411                     .filter(p -> p.toString().endsWith(".jar"))
412                     .forEach(f -> {
413                         try {
414                             urls.add(f.toUri().toURL());
415                         } catch (MalformedURLException e) {
416                             throw new ExecutorException("Failed to build classpath: " + f, e);
417                         }
418                     });
419         } catch (IOException e) {
420             throw new ExecutorException("Failed to build classpath: " + e, e);
421         }
422         if (urls.isEmpty()) {
423             throw new IllegalArgumentException("Invalid Maven home directory; boot is empty");
424         }
425         return new URLClassLoader(
426                 urls.toArray(new URL[0]), ClassLoader.getSystemClassLoader().getParent());
427     }
428 
429     protected String getMavenVersion(Class<?> clazz) throws IOException {
430         Properties props = new Properties();
431         try (InputStream is = clazz.getResourceAsStream("/META-INF/maven/org.apache.maven/maven-core/pom.properties")) {
432             if (is != null) {
433                 props.load(is);
434             }
435             String version = props.getProperty("version");
436             if (version != null) {
437                 return version;
438             }
439             return UNKNOWN_VERSION;
440         }
441     }
442 }