package alien4cloud.plugin;
import java.io.IOException;
import java.net.URL;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.UUID;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import javax.inject.Inject;
import org.apache.commons.collections4.MapUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.config.ConstructorArgumentValues;
import org.springframework.beans.factory.support.GenericBeanDefinition;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.stereotype.Component;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import alien4cloud.dao.FilterUtil;
import alien4cloud.dao.IGenericSearchDAO;
import alien4cloud.dao.model.GetMultipleDataResult;
import alien4cloud.exception.AlreadyExistException;
import alien4cloud.exception.NotFoundException;
import alien4cloud.plugin.exception.MissingPlugingDescriptorFileException;
import alien4cloud.plugin.exception.PluginLoadingException;
import alien4cloud.plugin.model.ManagedPlugin;
import alien4cloud.plugin.model.PluginComponent;
import alien4cloud.plugin.model.PluginComponentDescriptor;
import alien4cloud.plugin.model.PluginConfiguration;
import alien4cloud.plugin.model.PluginDescriptor;
import alien4cloud.plugin.model.PluginUsage;
import alien4cloud.utils.ClassLoaderUtil;
import alien4cloud.utils.FileUtil;
import alien4cloud.utils.ReflectionUtil;
import alien4cloud.utils.SpringUtils;
import alien4cloud.utils.YamlParserUtil;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Manages plugins.
*/
@Slf4j
@Component("plugin-manager")
@SuppressWarnings({ "rawtypes", "unchecked" })
public class PluginManager {
private static final String UNKNOWN_PLUGIN_COMPONENT_TYPE = "Unknown component type";
private static final String LIB_DIRECTORY = "lib";
private static final String UI_DIRECTORY = "ui";
private static final String PLUGIN_DESCRIPTOR_FILE = "META-INF/plugin.yml";
@Value("${directories.alien}/plugins")
private String pluginsDirectory; // directory in which plugins are placed so they are loaded when alien is starting - for initialization.
@Value("${directories.alien}/work/plugins/content")
private String pluginsWorkDirectory; // directory in which alien place plugins that are loaded.
@Value("${directories.alien}/work/plugins/ui")
private String pluginsUiDirectory; // directory in which alien place ui files from plugins so they are available from clients.
@Resource(name = "alien-es-dao")
private IGenericSearchDAO alienDAO;
@Resource
private ApplicationContext alienContext;
private Map<String, ManagedPlugin> pluginContexts = Maps.newHashMap();
private List<PluginLinker> linkers = Lists.newArrayList();;
@Inject
public void setLinkers(List<IPluginLinker> pluginLinkers) {
for (IPluginLinker pluginLinker : pluginLinkers) {
linkers.add(new PluginLinker(pluginLinker, getLinkedType(pluginLinker)));
}
}
/**
* Unload all plugins from alien4cloud.
*/
public void unloadAllPlugins() {
log.info("Unloading plugins");
GetMultipleDataResult<Plugin> results = alienDAO.find(Plugin.class, FilterUtil.fromKeyValueCouples("enabled", "true"), Integer.MAX_VALUE);
for (Plugin plugin : results.getData()) {
unloadPlugin(plugin.getId(), false, false);
}
log.info("{} Plugins unloaded", results.getData().length);
}
/**
* Initialize the plugin manager and create directories.
*
* @throws IOException In case we fail to create the plugin directory.
*/
@PostConstruct
public void postConstruct() throws IOException {
// Ensure plugin directory exists.
Path path = FileSystems.getDefault().getPath(pluginsWorkDirectory);
if (!Files.exists(path)) {
Files.createDirectories(path);
log.info("Plugin work directory created at <" + path.toAbsolutePath().toString() + ">");
}
}
/**
* Load all enabled plugins in alien4cloud.
*/
public void initialize() {
log.info("Initializing plugins");
// Load enabled plugins in alien, query using max value as anyway we must be able to load all plugins in memory.
GetMultipleDataResult<Plugin> results = alienDAO.find(Plugin.class, FilterUtil.fromKeyValueCouples("enabled", "true"), Integer.MAX_VALUE);
loadPlugins(results.getData());
log.info("{} Plugins initialized.", results.getData().length);
}
/**
* Load all the plugins that have their dependencies fulfilled by loaded plugin.
*
* @param plugins the plugins to load.
*/
private void loadPlugins(Plugin[] plugins) {
List<Plugin> missingDependencyPlugins = Lists.newArrayList();
for (Plugin plugin : plugins) {
// if the plugin has no unresolved dependency, load it
if (getMissingDependencies(plugin).size() == 0) {
try {
loadPlugin(plugin);
} catch (PluginLoadingException e) {
log.error("Alien server Initialization: failed to load plugin <" + plugin.getId() + ">", e);
disablePlugin(plugin.getId());
}
} else {
missingDependencyPlugins.add(plugin);
}
}
if (missingDependencyPlugins.size() == plugins.length) {
// No plugins have been loaded meaning that remaining plugins are not loadable because some dependencies are missing
for (Plugin plugin : plugins) {
log.error("Failed to load plugin <" + plugin.getId() + "> as some dependencies are missing <" + getMissingDependencies(plugin) + ">");
disablePlugin(plugin.getId());
}
} else {
if (missingDependencyPlugins.size() > 0) {
loadPlugins(missingDependencyPlugins.toArray(new Plugin[missingDependencyPlugins.size()]));
}
}
}
private Set<String> getMissingDependencies(Plugin plugin) {
Set<String> missingDependencies = Sets.newHashSet();
String[] dependencies = plugin.getDescriptor().getDependencies();
if (dependencies != null && dependencies.length > 0) {
for (String dependency : dependencies) {
if (this.pluginContexts.get(dependency) == null) {
missingDependencies.add(dependency);
}
}
}
return missingDependencies;
}
private Class<?> getLinkedType(IPluginLinker<?> linker) {
return ReflectionUtil.getGenericArgumentType(linker.getClass(), IPluginLinker.class, 0);
}
/**
* Upload a plugin from a given path.
*
* @param uploadedPluginPath The path of the plugin to upload.
* @return the uploaded plugin
* @throws IOException In case there is an issue with the access to the plugin file.
* @throws PluginLoadingException
* @throws AlreadyExistException if a plugin with the same id already exists in the repository
* @throws MissingPlugingDescriptorFileException
*/
public Plugin uploadPlugin(Path uploadedPluginPath) throws PluginLoadingException, IOException, MissingPlugingDescriptorFileException {
// load the plugin descriptor
FileSystem fs = FileSystems.newFileSystem(uploadedPluginPath, null);
PluginDescriptor descriptor;
try {
try {
descriptor = YamlParserUtil.parseFromUTF8File(fs.getPath(PLUGIN_DESCRIPTOR_FILE), PluginDescriptor.class);
} catch (IOException e) {
if (e instanceof NoSuchFileException) {
throw new MissingPlugingDescriptorFileException();
} else {
throw e;
}
}
String pluginPathId = getPluginPathId();
Plugin plugin = new Plugin(descriptor, pluginPathId);
// check plugin already exists and is enabled
if (pluginContexts.get(plugin.getId()) != null) {
log.warn("Uploading Plugin <{}> impossible (already exists and enabled)", plugin.getId());
throw new AlreadyExistException("A plugin with the given id already exists and is enabled.");
}
Plugin oldPlugin = alienDAO.findById(Plugin.class, plugin.getId());
if (oldPlugin != null) {
// remove all files for the old plugin but keep configuration.
removePlugin(plugin.getId(), false);
}
Path pluginPath = getPluginPath(pluginPathId);
FileUtil.unzip(uploadedPluginPath, pluginPath);
// copy ui directory in case it exists
Path pluginUiSourcePath = pluginPath.resolve(UI_DIRECTORY);
Path pluginUiPath = getPluginUiPath(pluginPathId);
if (Files.exists(pluginUiSourcePath)) {
FileUtil.copy(pluginUiSourcePath, pluginUiPath);
}
loadPlugin(plugin);
plugin.setConfigurable(isPluginConfigurable(plugin.getId()));
alienDAO.save(plugin);
log.info("Plugin <" + plugin.getId() + "> has been enabled.");
return plugin;
} finally {
fs.close();
}
}
private void unloadPlugin(String pluginId, boolean disable, boolean remove) {
ManagedPlugin managedPlugin = pluginContexts.get(pluginId);
if (managedPlugin != null) {
// send events to plugin loading callbacks
for (IPluginLoadingCallback callback : SpringUtils.getBeansOfType(alienContext, IPluginLoadingCallback.class)) {
callback.onPluginClosed(managedPlugin);
}
managedPlugin.getPluginContext().stop();
// destroy the plugin context
managedPlugin.getPluginContext().destroy();
}
// unlink the plugin
for (PluginLinker linker : linkers) {
linker.linker.unlink(pluginId);
}
// eventually remove it from elastic search and disk.
if (remove) {
removePlugin(pluginId, true);
} else if (disable) {
disablePlugin(pluginId);
}
pluginContexts.remove(pluginId);
}
private void removePlugin(String pluginId, boolean deleteConfig) {
Plugin plugin = alienDAO.findById(Plugin.class, pluginId);
Path pluginPath = getPluginPath(plugin.getPluginPathId());
Path pluginUiPath = getPluginUiPath(plugin.getPluginPathId());
alienDAO.delete(Plugin.class, pluginId);
// remove also the configuration
if (deleteConfig) {
alienDAO.delete(PluginConfiguration.class, pluginId);
}
// try to delete the plugin dir in the repo
try {
FileUtil.delete(pluginPath);
FileUtil.delete(getPluginZipFilePath(pluginId));
FileUtil.delete(pluginUiPath);
} catch (IOException e) {
log.error("Failed to delete the plugin <" + pluginId + "> in the repository. You'll have to do it manually", e);
}
}
/**
* Disable a plugin.
*
* @param pluginId The id of the plugin to disable.
* @param remove If true the plugin is not only disabled but also removed from the plugin repository.
* @return Empty list if the plugin was successfully disabled (and removed), or a list of usages that prevent the plugin to be disabled/removed.
*/
public List<PluginUsage> disablePlugin(String pluginId, boolean remove) {
List<PluginUsage> usages = Lists.newArrayList();
ManagedPlugin managedPlugin = pluginContexts.get(pluginId);
if (managedPlugin != null) {
for (PluginLinker linker : linkers) {
usages.addAll(linker.linker.usage(pluginId));
}
}
if (usages.isEmpty()) {
unloadPlugin(pluginId, true, remove);
}
return usages;
}
private void disablePlugin(String pluginId) {
Plugin plugin = alienDAO.findById(Plugin.class, pluginId);
plugin.setEnabled(false);
alienDAO.save(plugin);
}
/**
* Enable a plugin in alien.
*
* @param pluginId The id of the plugin to load.
* @throws PluginLoadingException In case plugin loading fails.
*/
public void enablePlugin(String pluginId) throws PluginLoadingException {
if (this.pluginContexts.get(pluginId) != null) {
log.info("Plugin <" + pluginId + "> is already loaded.");
return;
}
log.info("Loading plugin <" + pluginId + ">");
// save the plugin's file in the work directory
Plugin plugin = alienDAO.findById(Plugin.class, pluginId);
if (plugin == null) {
throw new NotFoundException("The plugin <" + pluginId + "> doesn't exists in alien.");
}
loadPlugin(plugin);
plugin.setEnabled(true);
alienDAO.save(plugin);
log.info("Plugin <" + pluginId + "> has been enabled.");
}
private void loadPlugin(Plugin plugin) throws PluginLoadingException {
if (pluginContexts.containsKey(plugin.getId())) {
log.debug("Do not load plugin {} as it is already loaded.", plugin.getId());
}
try {
Path pluginPath = getPluginPath(plugin.getPluginPathId());
Path pluginUiPath = getPluginUiPath(plugin.getPluginPathId());
loadPlugin(plugin, pluginPath, pluginUiPath);
} catch (Exception e) {
log.error("Failed to load plugin <" + plugin.getId() + "> alien will ignore this plugin.", e);
throw new PluginLoadingException("Failed to load plugin <" + plugin.getId() + ">", e);
}
}
private String getPluginPathId() {
// JVM has a bug that makes a classloader keep a lock on loaded files while the VM is running.
// we create a unique random folder for plugins so the lock cannot prevent to delete and re-upload the same plugin.
String pluginId = UUID.randomUUID().toString();
Path pluginPath = getPluginPath(pluginId);
while (Files.exists(pluginPath)) {
pluginId = UUID.randomUUID().toString();
pluginPath = getPluginPath(pluginId);
}
return pluginId;
}
private Path getPluginPath(String pluginPathId) {
return FileSystems.getDefault().getPath(pluginsWorkDirectory, pluginPathId);
}
private Path getPluginUiPath(String pluginPathId) {
return FileSystems.getDefault().getPath(pluginsUiDirectory, pluginPathId);
}
private Path getPluginZipFilePath(String pluginId) {
String pluginFileName = pluginId.replaceAll(":", "-");
return FileSystems.getDefault().getPath(pluginsWorkDirectory, pluginFileName + ".cpa");
}
/**
* Actually load and link a plugin in Alien 4 Cloud.
*
* @param plugin The plugin the load and link.
* @param pluginPath The path to the directory that contains the un-zipped plugin.
* @param pluginUiPath The path in which the ui files are located.
* @throws IOException In case there is an IO issue with the file.
* @throws ClassNotFoundException If we cannot load the class
*/
private void loadPlugin(Plugin plugin, Path pluginPath, Path pluginUiPath) throws IOException, ClassNotFoundException {
// create a class loader to manage this plugin.
final List<URL> classPathUrls = Lists.newArrayList();
pluginPath = pluginPath.toRealPath();
classPathUrls.add(pluginPath.toUri().toURL());
Path libPath = pluginPath.resolve(LIB_DIRECTORY);
if (Files.exists(libPath)) {
Files.walkFileTree(libPath, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
file.endsWith(".jar");
classPathUrls.add(file.toUri().toURL());
return FileVisitResult.CONTINUE;
}
});
}
ClassLoader pluginClassLoader = new PluginClassloader(classPathUrls.toArray(new URL[classPathUrls.size()]),
Thread.currentThread().getContextClassLoader());
// load a spring context for the plugin that will be a child of the current spring context
AnnotationConfigApplicationContext pluginContext = new AnnotationConfigApplicationContext();
pluginContext.setParent(alienContext);
pluginContext.setClassLoader(pluginClassLoader);
// Register beans from dependencies
// TODO should we allow some pure ui plugins ? and if the plugin doesn't have any config class? for ex a typical ui plugin ?
registerDependencies(plugin, pluginContext);
if (plugin.getDescriptor().getConfigurationClass() != null) {
pluginContext.register(pluginClassLoader.loadClass(plugin.getDescriptor().getConfigurationClass()));
}
// Register the context so that it can be injected by other beans
// pluginContext.getBeanFactory().registerSingleton("alien-plugin-context", managedPlugin);
GenericBeanDefinition beanDefinition = new GenericBeanDefinition();
beanDefinition.setPrimary(true);
beanDefinition.setBeanClass(ManagedPlugin.class);
ConstructorArgumentValues constructorArgumentValues = new ConstructorArgumentValues();
constructorArgumentValues.addIndexedArgumentValue(0, pluginContext);
constructorArgumentValues.addIndexedArgumentValue(1, plugin);
constructorArgumentValues.addIndexedArgumentValue(2, pluginPath);
constructorArgumentValues.addIndexedArgumentValue(3, pluginUiPath);
beanDefinition.setConstructorArgumentValues(constructorArgumentValues);
pluginContext.registerBeanDefinition("alien-plugin-context", beanDefinition);
// Use plugin classloader as context classloader as some codes still use this
ClassLoaderUtil.runWithContextClassLoader(pluginClassLoader, () -> {
pluginContext.refresh();
pluginContext.start();
});
ManagedPlugin managedPlugin = (ManagedPlugin) pluginContext.getBean("alien-plugin-context");
Map<String, PluginComponentDescriptor> componentDescriptors = getPluginComponentDescriptorAsMap(plugin);
// expose plugin elements so they are available to plugins that depends from them.
expose(managedPlugin, componentDescriptors);
// register plugin elements in Alien
link(plugin, managedPlugin, componentDescriptors);
// install static resources to be available for the application.
pluginContexts.put(plugin.getId(), managedPlugin);
}
private void registerDependencies(Plugin plugin, AnnotationConfigApplicationContext pluginContext) {
if (plugin.getDescriptor().getDependencies() == null) {
return; // no dependencies for this plugin.
}
for (String dependency : plugin.getDescriptor().getDependencies()) {
ManagedPlugin dependencyPlugin = this.pluginContexts.get(dependency);
for (Entry<String, Object> exposed : dependencyPlugin.getExposedBeans().entrySet()) {
pluginContext.getBeanFactory().registerSingleton(exposed.getKey(), exposed.getValue());
}
}
}
/**
* Initialize the list of exposed beans for the given plugin.
*
* @param managedPlugin The plugin for which to configure exposed beans.
* @param componentDescriptors The components descriptor of the plugin.
*/
private void expose(ManagedPlugin managedPlugin, Map<String, PluginComponentDescriptor> componentDescriptors) {
Map<String, Object> exposedBeans = Maps.newHashMap();
for (Entry<String, PluginComponentDescriptor> componentDescriptorEntry : componentDescriptors.entrySet()) {
String beanName = componentDescriptorEntry.getValue().getBeanName();
// TODO handle NoSuchBeanDefinitionException with a nice error message to the user.
Object bean = managedPlugin.getPluginContext().getBean(beanName);
if (bean == null) {
log.warn("Plugin bean <" + beanName + "> is referenced in descriptor but doesn't exist in context.");
} else {
exposedBeans.put(beanName, bean);
}
}
managedPlugin.setExposedBeans(exposedBeans);
}
/**
* Link the plugin against alien components that may need to use it.
*
* @param plugin The plugin to link.
* @param managedPlugin The managed plugin related to the plugin.
* @param componentDescriptors The map of component descriptors.
*/
private void link(Plugin plugin, ManagedPlugin managedPlugin, Map<String, PluginComponentDescriptor> componentDescriptors) {
// Global linking (rest-mapping for example)
for (IPluginLoadingCallback callback : SpringUtils.getBeansOfType(alienContext, IPluginLoadingCallback.class)) {
callback.onPluginLoaded(managedPlugin);
}
// Specific bean linking to bean types.
for (PluginLinker linker : linkers) {
Map<String, ?> instancesToLink = managedPlugin.getPluginContext().getBeansOfType(linker.linkedType);
for (Entry<String, ?> instanceToLink : instancesToLink.entrySet()) {
linker.linker.link(plugin.getId(), instanceToLink.getKey(), instanceToLink.getValue());
PluginComponentDescriptor componentDescriptor = componentDescriptors.get(instanceToLink.getKey());
if (componentDescriptor == null) {
componentDescriptor = new PluginComponentDescriptor();
componentDescriptor.setBeanName(instanceToLink.getKey());
componentDescriptor.setName(instanceToLink.getKey());
}
componentDescriptor.setType(linker.linkedType.getSimpleName());
}
}
// Add a default undefined type for all componentDescriptor that haven't been linked.
for (PluginComponentDescriptor componentDescriptor : componentDescriptors.values()) {
if (componentDescriptor.getType() == null) {
componentDescriptor.setType(UNKNOWN_PLUGIN_COMPONENT_TYPE);
}
}
}
private Map<String, PluginComponentDescriptor> getPluginComponentDescriptorAsMap(Plugin plugin) {
Map<String, PluginComponentDescriptor> componentDescriptors = Maps.newHashMap();
if (plugin == null || plugin.getDescriptor() == null || plugin.getDescriptor().getComponentDescriptors() == null) {
return componentDescriptors;
}
for (PluginComponentDescriptor componentDescriptor : plugin.getDescriptor().getComponentDescriptors()) {
componentDescriptors.put(componentDescriptor.getBeanName(), componentDescriptor);
}
return componentDescriptors;
}
/**
* The the plugin descriptor for a given plugin.
*
* @param pluginId The id of the plugin for which to get the descriptor.
* @return The plugin descriptor for the given plugin.
*/
public PluginDescriptor getPluginDescriptor(String pluginId) {
return pluginContexts.get(pluginId).getPlugin().getDescriptor();
}
private Class<?> getConfigurationType(IPluginConfigurator<?> configurator) {
return ReflectionUtil.getGenericArgumentType(configurator.getClass(), IPluginConfigurator.class, 0);
}
/**
* Return true if the plugin can be configured using a configuration object (basically if the plugin spring context contains an instance of
* {@link IPluginConfigurator}.
*
* @param pluginId Id of the plugin for which to know if configurable.
* @return True if the plugin can be configured, false if not.
*/
public boolean isPluginConfigurable(String pluginId) {
AnnotationConfigApplicationContext pluginContext = pluginContexts.get(pluginId).getPluginContext();
Map<String, IPluginConfigurator> configurators = pluginContext.getBeansOfType(IPluginConfigurator.class);
return MapUtils.isNotEmpty(configurators);
}
/**
* Get the class of the configuration object for a given plugin.
*
* @param pluginId Id of the plugin for which to get configuration object's class.
* @return The class of the plugin configuration object.
*/
public Class<?> getConfigurationType(String pluginId) {
AnnotationConfigApplicationContext pluginContext = pluginContexts.get(pluginId).getPluginContext();
Map<String, IPluginConfigurator> configurators = pluginContext.getBeansOfType(IPluginConfigurator.class);
if (MapUtils.isNotEmpty(configurators)) {
// TODO: manage case multiple configuration beans
return getConfigurationType(configurators.values().iterator().next());
}
return null;
}
/**
* Get the instance of the {@link IPluginConfigurator} for a given plugin.
*
* @param pluginId Id of the plugin for which to get a the {@link IPluginConfigurator}
* @return Null if no {@link IPluginConfigurator} is defined within the plugin's spring context or the first instance of {@link IPluginConfigurator}.
*/
public <T> IPluginConfigurator<T> getConfiguratorFor(String pluginId) {
AnnotationConfigApplicationContext pluginContext = pluginContexts.get(pluginId).getPluginContext();
Map<String, IPluginConfigurator> configurators = pluginContext.getBeansOfType(IPluginConfigurator.class);
if (MapUtils.isNotEmpty(configurators)) {
// TODO: manage case multiple configuration beans
return configurators.values().iterator().next();
}
return null;
}
public Map<String, ManagedPlugin> getPluginContexts() {
return pluginContexts;
}
public List<PluginComponent> getPluginComponents(String type) {
List<PluginComponent> pluginComponents = new ArrayList<>();
for (ManagedPlugin plugin : pluginContexts.values()) {
PluginDescriptor descriptor = plugin.getPlugin().getDescriptor();
if (descriptor.getComponentDescriptors() != null) {
for (PluginComponentDescriptor componentDescriptor : descriptor.getComponentDescriptors()) {
if (componentDescriptor.getType().equals(type)) {
pluginComponents
.add(new PluginComponent(plugin.getPlugin().getId(), descriptor.getName(), descriptor.getVersion(), componentDescriptor));
}
}
}
}
return pluginComponents;
}
@AllArgsConstructor(suppressConstructorProperties = true)
private final class PluginLinker<T> {
private IPluginLinker<T> linker;
private Class<T> linkedType;
}
}