All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.ligoj.bootstrap.resource.system.plugin.PluginsClassLoader Maven / Gradle / Ivy

/*
 * Licensed under MIT (https://github.com/ligoj/ligoj/blob/master/LICENSE)
 */
package org.ligoj.bootstrap.resource.system.plugin;

import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.ligoj.bootstrap.core.plugin.PluginException;

import lombok.Getter;
import lombok.extern.slf4j.Slf4j;

/**
 * Class Loader which load jars in {@value #PLUGINS_DIR} directory inside the home directory.
 */
@Slf4j
@Getter
public class PluginsClassLoader extends URLClassLoader {

	/**
	 * Safe mode property flag.
	 */
	private static final String ENABLED = "ligoj.plugin.enabled";

	/**
	 * System property name pointing to the home directory. When undefined, system user home directory will be used
	 */
	public static final String HOME_DIR_PROPERTY = "ligoj.home";

	/**
	 * Default home directory part used in addition of system user home directory : "/home/my-user" for sample.
	 */
	public static final String HOME_DIR_FOLDER = ".ligoj";
	/**
	 * Plug-ins directory inside the home property
	 */
	public static final String PLUGINS_DIR = "plugins";
	/**
	 * Plug-ins export directory inside the home property
	 */
	public static final String EXPORT_DIR = "export";

	/**
	 * Pattern used to extract the version from a JAR plugin file name.
	 */
	private static final Pattern VERSION_PATTERN = Pattern
			.compile("(-(\\d[\\da-zA-Z]*(\\.[\\da-zA-Z]+){1,3}(-SNAPSHOT)?))\\.jar$");

	/**
	 * The application home directory.
	 */
	private final Path homeDirectory;

	/**
	 * The plug-in directory, inside the home directory.
	 */
	private Path pluginDirectory;

	/**
	 * Read only plug-in safe mode. When false, external plug-ins are not participating to the classpath.
	 */
	@Getter
	protected final boolean enabled;

	/**
	 * Initialize the plug-in {@link URLClassLoader} and the related directories.
	 *
	 * @throws IOException
	 *             exception when reading plug-ins directory
	 */
	public PluginsClassLoader() throws IOException {
		super(new URL[0], Thread.currentThread().getContextClassLoader());
		this.enabled = Boolean.valueOf(System.getProperty(ENABLED, "true"));
		this.homeDirectory = computeHome();
		this.pluginDirectory = this.homeDirectory.resolve(PLUGINS_DIR);

		// Create the plug-in directory as needed
		log.info("Initialize the plug-ins from directory from {}", homeDirectory);
		Files.createDirectories(this.pluginDirectory);

		// Add the home it self in the class-path
		addURL(this.homeDirectory.toUri().toURL());

		if (!isEnabled()) {
			// Ignore this refresh keep original class-path
			log.info("SAFE MODE - Plugins classloader is disabled");
			return;
		}

		completeClasspath();
	}

	/**
	 * Complete the class-path with plug-ins jars
	 */
	private void completeClasspath() throws IOException {
		// Mapping from "version file" to Path
		// Key : The filename without extension and with extended comparable version
		// Value : The resolved Path
		final Map versionFileToPath = new HashMap<>();

		// Ordered last version (to be enabled) plug-ins.
		final Map enabledPlugins = getInstalledPlugins(versionFileToPath);

		// Add the filtered plug-in files to the class-path
		for (final Entry plugin : enabledPlugins.entrySet()) {
			final URI uri = versionFileToPath.get(plugin.getValue()).toUri();
			log.debug("Add plugin {}", uri);
			copyExportedResources(plugin.getKey(), versionFileToPath.get(plugin.getValue()));
			addURL(uri.toURL());
		}
		log.info("Plugins ClassLoader has added {} plug-ins and ignored {} old plug-ins", enabledPlugins.size(),
				versionFileToPath.size() - enabledPlugins.size());
	}

	/**
	 * Return the mapping of the elected last plug-in name to the corresponding version file.
	 *
	 * @param versionFileToPath
	 *            The mapping filled by this method. Key : The filename without extension and with extended comparable
	 *            version. Value : The resolved Path.
	 * @return The mapping of the elected last plug-in name to the corresponding version file. Key: the plug-in
	 *         artifactId resolved from the filename. Value: the plug-in artifactId with its extended comparable
	 *         version.
	 * @throws IOException
	 *             When file list failed.
	 */
	private Map getInstalledPlugins(final Map versionFileToPath) throws IOException {
		final Map versionFiles = new TreeMap<>();
		Files.list(this.pluginDirectory).filter(p -> p.toString().endsWith(".jar"))
				.forEach(path -> addVersionFile(versionFileToPath, versionFiles, path));
		final Map enabledPlugins = new TreeMap<>(Comparator.reverseOrder());

		// Remove old plug-in from the list
		versionFiles.keySet().stream().sorted(Comparator.reverseOrder())
				.forEach(p -> enabledPlugins.putIfAbsent(versionFiles.get(p), p));
		return enabledPlugins;
	}

	/**
	 * Return the mapping of the installed plug-ins. Only the last version is returned.
	 *
	 * @return The mapping of the elected last plug-in name to the corresponding version file. Key: the plug-in
	 *         artifactId resolved from the filename. Value: the plug-in artifactId with its extended comparable
	 *         version.
	 * @throws IOException
	 *             When file list failed.
	 */
	public Map getInstalledPlugins() throws IOException {
		return getInstalledPlugins(new HashMap<>());
	}

	/**
	 * Return the plug-in class loader from the current class loader.
	 *
	 * @return the closest {@link PluginsClassLoader} instance from the current thread's {@link ClassLoader}. May be
	 *         null.
	 */
	public static PluginsClassLoader getInstance() {
		return getInstance(Thread.currentThread().getContextClassLoader());
	}

	/**
	 * Return the plug-in class loader from the given class loader's hierarchy.
	 *
	 * @param cl
	 *            The {@link ClassLoader} to inspect.
	 * @return the closest {@link PluginsClassLoader} instance from the current thread's {@link ClassLoader}. May be
	 *         null.
	 */
	public static PluginsClassLoader getInstance(final ClassLoader cl) {
		if (cl == null) {
			// A separate class loader ?
			log.warn("PluginsClassLoader requested but not found in the current classloader hierarchy {}",
					Thread.currentThread().getContextClassLoader().toString());
			return null;
		}
		if (cl instanceof PluginsClassLoader) {
			// Class loader has been found
			return (PluginsClassLoader) cl;
		}

		// Try the parent
		return getInstance(cl.getParent());
	}

	/**
	 * Copy resources needed to be exported from the JAR plug-in to the home.
	 *
	 * @param plugin
	 *            The plug-in identifier.
	 * @param pluginFile
	 *            The target plug-in file.
	 * @throws IOException
	 *             When plug-in file cannot be read.
	 */
	protected void copyExportedResources(final String plugin, final Path pluginFile) throws IOException {
		try (FileSystem fileSystem = FileSystems.newFileSystem(pluginFile, this)) {
			final Path export = fileSystem.getPath("/" + EXPORT_DIR);
			if (Files.exists(export)) {
				final Path targetExport = getHomeDirectory().resolve(EXPORT_DIR);
				Files.walk(export).forEach(from -> copyExportedResource(plugin, targetExport, export, from));
			}
		}
	}

	/**
	 * Copy a resource as needed to be exported from the JAR plug-in to the home.
	 */
	private void copyExportedResource(final String plugin, final Path targetExport, final Path root, final Path from) {
		final Path dest = targetExport.resolve(root.relativize(from).toString());
		// Copy without overwrite
		if (!dest.toFile().exists()) {
			try {
				copy(from, dest);
			} catch (final IOException e) {
				throw new PluginException(plugin,
						String.format("Unable to copy exported resource %s to %s", from, dest.toString()), e);
			}
		}
	}

	/**
	 * Copy a resource needed to be exported from the JAR plug-in to the home.
	 *
	 * @param from
	 *            The source file to the destination file. Directories are not supported.
	 * @param dest
	 *            The destination file.
	 * @throws IOException
	 *             When plug-in file cannot be copied.
	 */
	protected void copy(final Path from, final Path dest) throws IOException {
		if (Files.isDirectory(from)) {
			Files.createDirectories(dest);
		} else {
			Files.copy(from, dest);
		}
	}

	private void addVersionFile(final Map versionFileToPath, final Map versionFiles,
			final Path path) {
		final String file = path.getFileName().toString();
		final Matcher matcher = VERSION_PATTERN.matcher(file);
		final String noVersionFile;
		final String fileWithExtVersion;
		if (matcher.find()) {
			// This plug-in has a version, extend the version for the next natural string ordering
			noVersionFile = file.substring(0, matcher.start());
			fileWithExtVersion = noVersionFile + "-" + toExtendedVersion(matcher.group(1));

		} else {
			// No version, the file will be kept with the lowest level version number
			noVersionFile = FilenameUtils.removeExtension(file);
			fileWithExtVersion = noVersionFile + "-0";
		}

		// Store the version files to keep later only the most recent one
		versionFileToPath.put(fileWithExtVersion, path);
		versionFiles.put(fileWithExtVersion, noVersionFile);
	}

	/**
	 * Convert a version to a comparable string and following the semver specification. Maximum 4 version ranges are
	 * accepted.
	 *
	 * @param version
	 *            The version string to convert. May be null
	 * @return The given version to be comparable with another version. Handle the 'SNAPSHOT' case considered has oldest
	 *         than the one without this suffix.
	 * @see PluginsClassLoader#toExtendedVersion(String)
	 */
	public static String toExtendedVersion(final String version) {
		final StringBuilder fileWithVersionExp = new StringBuilder();
		final String[] allFragments = { "0", "0", "0", "0" };
		final String[] versionFragments = ObjectUtils.defaultIfNull(StringUtils.split(version, "-."), allFragments);
		System.arraycopy(versionFragments, 0, allFragments, 0, versionFragments.length);
		Arrays.stream(allFragments).map(s -> StringUtils.leftPad(StringUtils.leftPad(s, 7, '0'), 8, 'Z'))
				.forEach(fileWithVersionExp::append);
		return fileWithVersionExp.toString();
	}

	/**
	 * Compute the right home directory for the application from the system properties.
	 *
	 * @return The computed home directory.
	 */
	protected Path computeHome() {
		final Path homeDir;
		if (System.getProperty(HOME_DIR_PROPERTY) == null) {
			// Non standard home directory
			homeDir = Paths.get(System.getProperty("user.home"), HOME_DIR_FOLDER);
			log.info(
					"Home directory is '{}', resolved from current home user location. Use '{}' system property to override this path",
					homeDir, HOME_DIR_PROPERTY);
		} else {
			// Home directory inside the system user's home directory
			homeDir = Paths.get(System.getProperty(HOME_DIR_PROPERTY));
			log.info("Home directory is '{}', resolved from the system property '{}'", homeDir, HOME_DIR_PROPERTY);
		}
		return homeDir;
	}

	/**
	 * Convert a fragment list to a {@link Path} inside the home directory. The intermediate directories are also
	 * created.
	 *
	 * @param fragments
	 *            The file fragments within the home directory.
	 * @return The {@link Path} reference.
	 * @throws IOException
	 *             When the parent directories creation failed.
	 * @since 2.2.4
	 */
	public Path toPath(final String... fragments) throws IOException {
		return toPath(getHomeDirectory(), fragments);
	}

	/**
	 * Get a file reference inside the given parent path. The parent directories are created as needed.
	 *
	 * @param parent
	 *            The parent path.
	 * @param fragments
	 *            The file fragments within the given parent.
	 * @return The {@link Path} reference.
	 * @throws IOException
	 *             When the parent directories creation failed.
	 */
	private Path toPath(final Path parent, final String... fragments) throws IOException {
		Path parentR = parent;
		for (int i = 0; i < fragments.length; i++) {
			parentR = parentR.resolve(fragments[i]);
		}
		// Ensure the parent path is created
		FileUtils.forceMkdir(parentR.getParent().toFile());
		return parentR;
	}
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy