EmbeddedMavenExecutor.java
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.maven.cling.executor.embedded;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import java.util.stream.Stream;
import org.apache.maven.api.cli.Executor;
import org.apache.maven.api.cli.ExecutorException;
import org.apache.maven.api.cli.ExecutorRequest;
import static java.util.Objects.requireNonNull;
/**
* Embedded executor implementation, that invokes Maven from installation directory within this same JVM but in isolated
* classloader. This class supports Maven 4.x and Maven 3.x as well. The ClassWorld of Maven is kept in memory as
* long as instance of this class is not closed. Subsequent execution requests over same installation home are cached.
*/
public class EmbeddedMavenExecutor implements Executor {
/**
* Maven4 supports multiple commands from same installation directory.
*/
protected static final Map<String, String> MVN4_MAIN_CLASSES = Map.of(
"mvn",
"org.apache.maven.cling.MavenCling",
"mvnenc",
"org.apache.maven.cling.MavenEncCling",
"mvnsh",
"org.apache.maven.cling.MavenShellCling");
/**
* Context holds things loaded up from given Maven Installation Directory.
*/
protected static final class Context {
private final URLClassLoader bootClassLoader;
private final String version;
private final Object classWorld;
private final Set<String> originalClassRealmIds;
private final ClassLoader tccl;
private final Map<String, Function<ExecutorRequest, Integer>> commands; // the commands
private final Collection<Object> keepAlive; // refs things to make sure no GC takes it away
private Context(
URLClassLoader bootClassLoader,
String version,
Object classWorld,
Set<String> originalClassRealmIds,
ClassLoader tccl,
Map<String, Function<ExecutorRequest, Integer>> commands,
Collection<Object> keepAlive) {
this.bootClassLoader = bootClassLoader;
this.version = version;
this.classWorld = classWorld;
this.originalClassRealmIds = originalClassRealmIds;
this.tccl = tccl;
this.commands = commands;
this.keepAlive = keepAlive;
}
}
protected final boolean cacheContexts;
protected final boolean useMavenArgsEnv;
protected final AtomicBoolean closed;
protected final InputStream originalStdin;
protected final PrintStream originalStdout;
protected final PrintStream originalStderr;
protected final Properties originalProperties;
protected final ClassLoader originalClassLoader;
protected final ConcurrentHashMap<Path, Context> contexts;
public EmbeddedMavenExecutor() {
this(true, true);
}
public EmbeddedMavenExecutor(boolean cacheContexts, boolean useMavenArgsEnv) {
this.cacheContexts = cacheContexts;
this.useMavenArgsEnv = useMavenArgsEnv;
this.closed = new AtomicBoolean(false);
this.originalStdin = System.in;
this.originalStdout = System.out;
this.originalStderr = System.err;
this.originalClassLoader = Thread.currentThread().getContextClassLoader();
this.contexts = new ConcurrentHashMap<>();
this.originalProperties = new Properties();
this.originalProperties.putAll(System.getProperties());
}
@Override
public int execute(ExecutorRequest executorRequest) throws ExecutorException {
requireNonNull(executorRequest);
if (closed.get()) {
throw new ExecutorException("Executor is closed");
}
validate(executorRequest);
Context context = mayCreate(executorRequest);
String command = executorRequest.command();
Function<ExecutorRequest, Integer> exec = context.commands.get(command);
if (exec == null) {
throw new IllegalArgumentException(
"Unknown command: '" + command + "' for '" + executorRequest.installationDirectory() + "'");
}
Thread.currentThread().setContextClassLoader(context.tccl);
try {
return exec.apply(executorRequest);
} catch (Exception e) {
throw new ExecutorException("Failed to execute", e);
} finally {
try {
disposeRuntimeCreatedRealms(context);
} finally {
System.setIn(originalStdin);
System.setOut(originalStdout);
System.setErr(originalStderr);
Thread.currentThread().setContextClassLoader(originalClassLoader);
System.setProperties(originalProperties);
if (!cacheContexts) {
doClose(context);
}
}
}
}
/**
* Unloads dynamically loaded things, like extensions created realms. Makes sure we go back to "initial state".
*/
protected void disposeRuntimeCreatedRealms(Context context) {
try {
Method getRealms = context.classWorld.getClass().getMethod("getRealms");
Method disposeRealm = context.classWorld.getClass().getMethod("disposeRealm", String.class);
List<Object> realms = (List<Object>) getRealms.invoke(context.classWorld);
for (Object realm : realms) {
String realmId = (String) realm.getClass().getMethod("getId").invoke(realm);
if (!context.originalClassRealmIds.contains(realmId)) {
disposeRealm.invoke(context.classWorld, realmId);
}
}
} catch (Exception e) {
throw new ExecutorException("Failed to dispose runtime created realms", e);
}
}
@Override
public String mavenVersion(ExecutorRequest executorRequest) throws ExecutorException {
requireNonNull(executorRequest);
validate(executorRequest);
if (closed.get()) {
throw new ExecutorException("Executor is closed");
}
return mayCreate(executorRequest).version;
}
protected Context mayCreate(ExecutorRequest executorRequest) {
Path mavenHome = ExecutorRequest.getCanonicalPath(executorRequest.installationDirectory());
if (cacheContexts) {
return contexts.computeIfAbsent(mavenHome, k -> doCreate(mavenHome, executorRequest));
} else {
return doCreate(mavenHome, executorRequest);
}
}
protected Context doCreate(Path mavenHome, ExecutorRequest executorRequest) {
if (!Files.isDirectory(mavenHome)) {
throw new IllegalArgumentException("Installation directory must point to existing directory: " + mavenHome);
}
if (!MVN4_MAIN_CLASSES.containsKey(executorRequest.command())) {
throw new IllegalArgumentException(
getClass().getSimpleName() + " does not support command " + executorRequest.command());
}
if (executorRequest.environmentVariables().isPresent()) {
throw new IllegalArgumentException(getClass().getSimpleName() + " does not support environment variables");
}
if (executorRequest.jvmArguments().isPresent()) {
throw new IllegalArgumentException(getClass().getSimpleName() + " does not support jvmArguments");
}
Path boot = mavenHome.resolve("boot");
Path m2conf = mavenHome.resolve("bin/m2.conf");
if (!Files.isDirectory(boot) || !Files.isRegularFile(m2conf)) {
throw new IllegalArgumentException(
"Installation directory does not point to Maven installation: " + mavenHome);
}
ArrayList<String> mavenArgs = new ArrayList<>();
String mavenArgsEnv = System.getenv("MAVEN_ARGS");
if (useMavenArgsEnv && mavenArgsEnv != null && !mavenArgsEnv.isEmpty()) {
Arrays.stream(mavenArgsEnv.split(" "))
.filter(s -> !s.trim().isEmpty())
.forEach(s -> mavenArgs.add(0, s));
}
Properties properties = prepareProperties(executorRequest);
// set ahead of time, if the mavenHome points to Maven4, as ClassWorld Launcher needs this property
properties.setProperty(
"maven.mainClass", requireNonNull(MVN4_MAIN_CLASSES.get(ExecutorRequest.MVN), "mainClass"));
System.setProperties(properties);
URLClassLoader bootClassLoader = createMavenBootClassLoader(boot, Collections.emptyList());
Thread.currentThread().setContextClassLoader(bootClassLoader);
try {
Class<?> launcherClass = bootClassLoader.loadClass("org.codehaus.plexus.classworlds.launcher.Launcher");
Object launcher = launcherClass.getDeclaredConstructor().newInstance();
Method configure = launcherClass.getMethod("configure", InputStream.class);
try (InputStream inputStream = Files.newInputStream(m2conf)) {
configure.invoke(launcher, inputStream);
}
Object classWorld = launcherClass.getMethod("getWorld").invoke(launcher);
Set<String> originalClassRealmIds = new HashSet<>();
// collect pre-created (in m2.conf) class realms as "original ones"; the rest are created at runtime
Method getRealms = classWorld.getClass().getMethod("getRealms");
List<Object> realms = (List<Object>) getRealms.invoke(classWorld);
for (Object realm : realms) {
Method realmGetId = realm.getClass().getMethod("getId");
originalClassRealmIds.add((String) realmGetId.invoke(realm));
}
Class<?> cliClass =
(Class<?>) launcherClass.getMethod("getMainClass").invoke(launcher);
String version = getMavenVersion(cliClass);
Map<String, Function<ExecutorRequest, Integer>> commands = new HashMap<>();
ArrayList<Object> keepAlive = new ArrayList<>();
if (version.startsWith("3.")) {
// 3.x
if (!ExecutorRequest.MVN.equals(executorRequest.command())) {
throw new IllegalArgumentException(getClass().getSimpleName() + " w/ mvn3 does not support command "
+ executorRequest.command());
}
keepAlive.add(cliClass.getClassLoader().loadClass("org.fusesource.jansi.internal.JansiLoader"));
Constructor<?> newMavenCli = cliClass.getConstructor(classWorld.getClass());
Object mavenCli = newMavenCli.newInstance(classWorld);
Class<?>[] parameterTypes = {String[].class, String.class, PrintStream.class, PrintStream.class};
Method doMain = cliClass.getMethod("doMain", parameterTypes);
commands.put(ExecutorRequest.MVN, r -> {
System.setProperties(prepareProperties(r));
try {
ArrayList<String> args = new ArrayList<>(mavenArgs);
args.addAll(r.arguments());
PrintStream stdout = r.stdOut().isEmpty()
? null
: new PrintStream(r.stdOut().orElseThrow(), true);
PrintStream stderr = r.stdErr().isEmpty()
? null
: new PrintStream(r.stdErr().orElseThrow(), true);
return (int) doMain.invoke(mavenCli, new Object[] {
args.toArray(new String[0]), r.cwd().toString(), stdout, stderr
});
} catch (Exception e) {
throw new ExecutorException("Failed to execute", e);
}
});
} else {
// assume 4.x
keepAlive.add(cliClass.getClassLoader().loadClass("org.jline.nativ.JLineNativeLoader"));
for (Map.Entry<String, String> cmdEntry : MVN4_MAIN_CLASSES.entrySet()) {
Class<?> cmdClass = cliClass.getClassLoader().loadClass(cmdEntry.getValue());
Method mainMethod = cmdClass.getMethod(
"main",
String[].class,
classWorld.getClass(),
InputStream.class,
OutputStream.class,
OutputStream.class);
commands.put(cmdEntry.getKey(), r -> {
System.setProperties(prepareProperties(r));
try {
ArrayList<String> args = new ArrayList<>(mavenArgs);
args.addAll(r.arguments());
return (int) mainMethod.invoke(
null,
args.toArray(new String[0]),
classWorld,
r.stdIn().orElse(null),
r.stdOut().orElse(null),
r.stdErr().orElse(null));
} catch (Exception e) {
throw new ExecutorException("Failed to execute", e);
}
});
}
}
return new Context(
bootClassLoader,
version,
classWorld,
originalClassRealmIds,
cliClass.getClassLoader(),
commands,
keepAlive);
} catch (Exception e) {
throw new ExecutorException("Failed to create executor", e);
} finally {
Thread.currentThread().setContextClassLoader(originalClassLoader);
System.setProperties(originalProperties);
}
}
protected Properties prepareProperties(ExecutorRequest request) {
System.setProperties(null); // this "inits" them!
Properties properties = new Properties();
properties.putAll(System.getProperties()); // get mandatory/expected init-ed above
properties.setProperty("user.dir", request.cwd().toString());
properties.setProperty("user.home", request.userHomeDirectory().toString());
Path mavenHome = request.installationDirectory();
properties.setProperty("maven.home", mavenHome.toString());
properties.setProperty(
"maven.multiModuleProjectDirectory", request.cwd().toString());
// Maven 3.x
properties.setProperty(
"library.jansi.path", mavenHome.resolve("lib/jansi-native").toString());
// Maven 4.x
properties.setProperty(
"library.jline.path", mavenHome.resolve("lib/jline-native").toString());
if (request.jvmSystemProperties().isPresent()) {
properties.putAll(request.jvmSystemProperties().get());
}
return properties;
}
@Override
public void close() throws ExecutorException {
if (closed.compareAndExchange(false, true)) {
try {
ArrayList<Exception> exceptions = new ArrayList<>();
for (Context context : contexts.values()) {
try {
doClose(context);
} catch (Exception e) {
exceptions.add(e);
}
}
if (!exceptions.isEmpty()) {
ExecutorException e = new ExecutorException("Could not close cleanly");
exceptions.forEach(e::addSuppressed);
throw e;
}
} finally {
System.setProperties(originalProperties);
}
}
}
protected void doClose(Context context) throws ExecutorException {
Thread.currentThread().setContextClassLoader(context.bootClassLoader);
try {
try {
((Closeable) context.classWorld).close();
} finally {
context.bootClassLoader.close();
}
} catch (Exception e) {
throw new ExecutorException("Failed to close cleanly", e);
} finally {
Thread.currentThread().setContextClassLoader(originalClassLoader);
}
}
protected void validate(ExecutorRequest executorRequest) throws ExecutorException {}
protected URLClassLoader createMavenBootClassLoader(Path boot, List<URL> extraClasspath) {
ArrayList<URL> urls = new ArrayList<>(extraClasspath);
try (Stream<Path> stream = Files.list(boot)) {
stream.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".jar"))
.forEach(f -> {
try {
urls.add(f.toUri().toURL());
} catch (MalformedURLException e) {
throw new ExecutorException("Failed to build classpath: " + f, e);
}
});
} catch (IOException e) {
throw new ExecutorException("Failed to build classpath: " + e, e);
}
if (urls.isEmpty()) {
throw new IllegalArgumentException("Invalid Maven home directory; boot is empty");
}
return new URLClassLoader(
urls.toArray(new URL[0]), ClassLoader.getSystemClassLoader().getParent());
}
protected String getMavenVersion(Class<?> clazz) throws IOException {
Properties props = new Properties();
try (InputStream is = clazz.getResourceAsStream("/META-INF/maven/org.apache.maven/maven-core/pom.properties")) {
if (is != null) {
props.load(is);
}
String version = props.getProperty("version");
if (version != null) {
return version;
}
return UNKNOWN_VERSION;
}
}
}