1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
54
55
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
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
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
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
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 }