
org.ligoj.bootstrap.resource.system.plugin.SystemPluginResource Maven / Gradle / Ivy
/*
* Licensed under MIT (https://github.com/ligoj/ligoj/blob/master/LICENSE)
*/
package org.ligoj.bootstrap.resource.system.plugin;
import jakarta.persistence.EntityManager;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.RegExUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.cxf.jaxrs.ext.multipart.Multipart;
import org.ligoj.bootstrap.core.INamableBean;
import org.ligoj.bootstrap.core.NamedBean;
import org.ligoj.bootstrap.core.dao.csv.CsvForJpa;
import org.ligoj.bootstrap.core.model.AbstractBusinessEntity;
import org.ligoj.bootstrap.core.model.AbstractStringKeyEntity;
import org.ligoj.bootstrap.core.plugin.FeaturePlugin;
import org.ligoj.bootstrap.core.plugin.PluginListener;
import org.ligoj.bootstrap.core.plugin.PluginVo;
import org.ligoj.bootstrap.core.plugin.PluginsClassLoader;
import org.ligoj.bootstrap.core.resource.TechnicalException;
import org.ligoj.bootstrap.core.validation.ValidationJsonException;
import org.ligoj.bootstrap.dao.system.SystemPluginRepository;
import org.ligoj.bootstrap.model.system.SystemPlugin;
import org.ligoj.bootstrap.model.system.SystemUser;
import org.ligoj.bootstrap.resource.system.configuration.ConfigurationResource;
import org.ligoj.bootstrap.resource.system.plugin.repository.Artifact;
import org.ligoj.bootstrap.resource.system.plugin.repository.EmptyRepositoryManager;
import org.ligoj.bootstrap.resource.system.plugin.repository.RepositoryManager;
import org.ligoj.bootstrap.resource.system.session.ISessionSettingsProvider;
import org.ligoj.bootstrap.resource.system.session.SessionSettings;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.context.restart.RestartEndpoint;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.data.domain.Persistable;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.ClassUtils;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Manage plug-in life-cycle.
*
* @see OSS
* lucene_search
*/
@Path("/system/plugin")
@Slf4j
@Component
@Transactional
@Produces(MediaType.APPLICATION_JSON)
public class SystemPluginResource implements ISessionSettingsProvider {
private static final String REPO_CENTRAL = "central";
/**
* Property identifying an array of plug-ins to ignore.
*/
private static final String PLUGIN_IGNORE = "ligoj.plugin.ignore";
/**
* Configuration key for plug-ins auto install flag.
*/
private static final String PLUGIN_INSTALL = "ligoj.plugin.install";
/**
* Configuration key for plug-ins auto install flag with javadoc.
*/
private static final String PLUGIN_INSTALL_JAVADOC = "ligoj.plugin.install.javadoc";
/**
* Configuration key for plug-ins auto update flag.
*/
private static final String PLUGIN_UPDATE = "ligoj.plugin.update";
/**
* Configuration key for default plug-ins repository.
*/
private static final String PLUGIN_REPOSITORY = "ligoj.plugin.repository";
/**
* Configuration key for plug-ins default Maven GroupId.
*/
private static final String PLUGIN_GROUP_ID = "ligoj.plugin.groupId";
/**
* Default Maven GroupId for plugin.
*/
private static final String DEFAULT_PLUGIN_GROUP_ID = "org.ligoj.plugin";
private static final RepositoryManager EMPTY_REPOSITORY = new EmptyRepositoryManager();
@Autowired
private SystemPluginRepository repository;
/**
* Injected CSV bean mapper for JPA shared with child classes.
*/
@Autowired
protected CsvForJpa csvForJpa;
/**
* Injected entity manager shared with child classes.
*/
@Autowired
protected EntityManager em;
@Autowired
private RestartEndpoint restartEndpoint;
/**
* Injected Spring context
*/
@Autowired
protected ApplicationContext context;
@Autowired
private ConfigurationResource configuration;
/**
* Return all plug-ins with details from a given repository.
*
* @param repository The repository identifier to query.
* @return All plug-ins with details.
* @throws IOException When the last version index file cannot be retrieved.
*/
@GET
public List findAll(@QueryParam("repository") @DefaultValue(REPO_CENTRAL) final String repository)
throws IOException {
// Get the available plug-ins
Map lastVersion = Collections.emptyMap();
try {
lastVersion = getLastPluginVersions(repository);
} catch (IOException ioe) {
log.warn("Unable to get latest version from repository {}", repository, ioe);
}
if (lastVersion.isEmpty()) {
log.warn("Unable to get latest version from repository {}", repository);
}
final var enabledFeatures = context.getBeansOfType(FeaturePlugin.class);
final var lastVersionF = lastVersion;
// Get the enabled plug-in features
final var enabled = this.repository.findAll().stream()
.map(p -> toVo(lastVersionF, p,
enabledFeatures.values().stream().filter(f -> p.getKey().equals(f.getKey())).findFirst()
.orElse(null)))
.filter(Objects::nonNull)
.collect(Collectors.toMap(p -> p.getPlugin().getArtifact(), Function.identity()));
// Add pending installation: available but not yet enabled plug-ins
getPluginClassLoader().getInstalledPlugins().forEach((id, v) -> {
enabled.computeIfPresent(id, (k, p) -> {
// Check if it's an update
if (!p.getPlugin().getVersion().equals(toTrimmedVersion(v))) {
// Corresponds to a different version
p.setLatestLocalVersion(toTrimmedVersion(v));
}
p.setDeleted(isDeleted(p));
return p;
});
// Add new plug-ins
enabled.computeIfAbsent(id, k -> {
final var plugin = new SystemPlugin();
plugin.setArtifact(k);
plugin.setKey("?:" + Arrays.stream(k.split("-")).skip(1).collect(Collectors.joining("-")));
final var p = new PluginVo();
p.setId(k);
p.setName(k);
p.setPlugin(plugin);
p.setLatestLocalVersion(toTrimmedVersion(v));
return p;
});
});
//
return enabled.values().stream().sorted(Comparator.comparing(NamedBean::getId)).toList();
}
/**
* Indicate the plug-in is deleted or not.
*
* @param plugin The plug-in to check.
* @return true
when the plug-in is deleted locally from the FS.
*/
protected boolean isDeleted(final PluginVo plugin) {
return !new File(plugin.getLocation()).exists();
}
/**
* Convert an extended version to a trim one. Example:
*
* plugin-sample-Z0000001Z0000002Z0000003Z0000004
will be 1.2.3.4
* plugin-sample-Z0000001Z0000002Z0000003Z0000000
will be 1.2.3
* plugin-sample-Z0000001Z0000002Z0000003SNAPSHOT
will be 1.2.3-SNAPSHOT
* plugin-sample-1.2.3.4
will be 1.2.3.4
* 1.2.3.4
will be 1.2.3.4
* 1.2.3.4
will be 1.2.3.4
* 1.2.3.0
will be 1.2.3
* 0.1.2.3
will be 0.1.2.3
*
*
* @param extendedVersion The extended version. Trim version is also accepted.
* @return Trim version.
*/
protected String toTrimmedVersion(final String extendedVersion) {
var trim = Arrays.stream(StringUtils.split(extendedVersion, "-Z.")).dropWhile(s -> !s.matches("^(Z?\\d+.*)"))
.map(s -> StringUtils.defaultIfBlank(RegExUtils.replaceFirst(s, "^0+", ""), "0"))
.collect(Collectors.joining(".")).replace(".SNAPSHOT", "-SNAPSHOT")
.replaceFirst("([^-])SNAPSHOT", "$1-SNAPSHOT");
if (trim.endsWith(".0") && StringUtils.countMatches(trim, '.') > 2) {
trim = StringUtils.removeEnd(trim, ".0");
}
return trim;
}
/**
* Build the plug-in information from the plug-in itself and the last version being available.
*/
private PluginVo toVo(final Map lastVersion, final SystemPlugin p, final FeaturePlugin feature) {
if (feature == null) {
// Plug-in is no more available or in fail-safe mode
return null;
}
final var extension = context.getBeansOfType(PluginListener.class).values().stream().findFirst();
// Plug-in implementation is available
final var vo = extension.map(PluginListener::toVo).orElse(PluginVo::new).get();
vo.setId(p.getKey());
vo.setName(StringUtils.removeStart(feature.getName(), "Ligoj - Plugin "));
vo.setLocation(getPluginLocation(feature).getPath());
vo.setVendor(feature.getVendor());
vo.setPlugin(p);
// Expose the resolve newer version
vo.setNewVersion(Optional
.ofNullable(lastVersion.get(p.getArtifact())).map(Artifact::getVersion).filter(v -> PluginsClassLoader
.toExtendedVersion(v).compareTo(PluginsClassLoader.toExtendedVersion(p.getVersion())) > 0)
.orElse(null));
extension.ifPresent(e -> e.fillVo(p, feature, vo));
return vo;
}
/**
* Search plug-ins in repository which can be installed.
*
* @param query The optional searched term.
* @param repository The repository identifier to query.
* @return All plug-ins artifacts name.
* @throws IOException When the last version index file cannot be retrieved.
*/
@GET
@Path("search")
public List search(@QueryParam("q") @DefaultValue("") final String query,
@QueryParam("repository") @DefaultValue(REPO_CENTRAL) final String repository) throws IOException {
return getLastPluginVersions(repository).values().stream().filter(a -> a.getArtifact().contains(query))
.toList();
}
/**
* Return the {@link RepositoryManager} with the given identifier.
*
* @param repository The repository identifier.
* @return The {@link RepositoryManager} with the given identifier or {@link #EMPTY_REPOSITORY}
*/
protected RepositoryManager getRepositoryManager(final String repository) {
return context.getBeansOfType(RepositoryManager.class).values().stream()
.filter(r -> r.getId().equals(repository)).findFirst().orElse(EMPTY_REPOSITORY);
}
/**
* Request a restart of the current application context in a separated thread.
*/
@PUT
@Path("restart")
public void restart() {
final var restartThread = new Thread(() -> restartEndpoint.restart(), "Restart"); // NOPMD
restartThread.setDaemon(false);
restartThread.start();
}
/**
* Request a reset of plug-in cache meta-data
*
* @param repository The repository identifier to reset.
*/
@PUT
@Path("cache")
public void invalidateLastPluginVersions(
@QueryParam("repository") @DefaultValue(REPO_CENTRAL) final String repository) {
getRepositoryManager(repository).invalidateLastPluginVersions();
}
/**
* Remove all versions the specified plug-in and the related (by name) plug-ins.
*
* @param artifact The Maven artifact identifier and also corresponding to the plug-in simple name.
* @throws IOException When the file cannot be read or deleted from the file system.
*/
@DELETE
@Path("{artifact:[\\w-]+}")
public void delete(@PathParam("artifact") final String artifact) throws IOException {
removeFilter(artifact, "(-.*)?");
log.info("Plugin {} has been deleted, restart is required", artifact);
}
/**
* Remove the specific version of a plug-in.
*
* @param artifact The Maven artifact identifier and also corresponding to the plug-in simple name.
* @param version The specific version.
* @throws IOException When the file cannot be read or deleted from the file system.
*/
@DELETE
@Path("{artifact:[\\w-]+}/{version}")
public void delete(@PathParam("artifact") final String artifact, @PathParam("version") final String version)
throws IOException {
removeFilter(artifact, "-" + version.replace(".", "\\."));
log.info("Plugin {} v{} has been deleted, restart is required", artifact, version);
}
private void removeFilter(final String artifact, final String filter) throws IOException {
try (var list = Files.list(getPluginClassLoader().getPluginDirectory())) {
list.filter(p -> p.getFileName().toString().matches("^" + artifact + filter + "\\.jar$"))
.forEach(p -> p.toFile().delete());
}
}
/**
* Upload a file of entries to create or update users. The whole entry is replaced.
*
* @param input The Maven artifact file.
* @param pluginId The Maven artifactId
.
* @param version The Maven version
.
*/
@PUT
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Path("upload")
public void upload(@Multipart("plugin-file") final InputStream input,
@Multipart("plugin-id") final String pluginId,
@Multipart("plugin-version") final String version) {
install(input, getPluginGroupId(), pluginId, version, "(local)", true, false);
}
/**
* Install or update to the last available version of given plug-in from the remote server.
*
* @param artifact The Maven artifact identifier and also corresponding to the plug-in simple name.
* @param repository The repository identifier to query.
* @param javadoc When true, the Javadoc is also installed and will contribute to OpenAPI documentation.
* @throws IOException When install failed.
*/
@POST
@Path("{artifact:[\\w-]+}")
public void install(@PathParam("artifact") final String artifact,
@QueryParam("repository") @DefaultValue(REPO_CENTRAL) final String repository,
@QueryParam("javadoc") @DefaultValue("true") final boolean javadoc) throws IOException {
final var resultItem = getLastPluginVersions(repository).get(artifact);
if (resultItem == null) {
// Plug-in not found, or not the last version
throw new ValidationJsonException("artifact",
String.format("No latest version on repository %s", repository));
}
install(artifact, resultItem.getVersion(), repository, javadoc);
}
String[] getBeanNamesWithPath() {
return context.getBeanNamesForAnnotation(Path.class);
}
String getClassLocation(final Class> clazz) {
return clazz.getProtectionDomain().getCodeSource().getLocation().getFile();
}
String getVersion(final Class> clazz) {
return clazz.getPackage().getImplementationVersion();
}
/**
* Install or update all javadoc plugin.
*
* @param repository The repository identifier to query.
* @return A map containing statistics.
* @throws IOException When install failed.
*/
@POST
@Path("javadoc/install")
public Map installJavadoc(@QueryParam("repository") @DefaultValue(REPO_CENTRAL) final String repository) throws IOException {
final var ignoredFeatures = Set.of("feature:iam:empty", "feature:welcome:data-rbac");
final var plugins = findAll(repository);
final var response = new HashMap();
var counter = 0;
for (final var plugin : plugins) {
log.info("Filtering plugin for javadoc '{}': {}", plugin.getId(), plugin);
if (plugin.getLocation() != null && plugin.getLocation().endsWith(".jar") && !ignoredFeatures.contains(plugin.getId())) {
// Packaged jar and not ignored feature
install(null, getPluginGroupId(), plugin.getPlugin().getArtifact(), plugin.getPlugin().getVersion(), repository, false, true);
} else {
log.info("Ignored plugin '{}'", plugin.getId());
}
counter++;
}
// Add built-in jar
final var jarArtifacts = new HashMap>();
final var packageToGroupId = Map.of("org.ligoj.bootstrap", "org.ligoj.bootstrap", "org.ligoj.app.resource", "org.ligoj.api");
Arrays.stream(getBeanNamesWithPath())
.map(context::getBean)
.map(Object::getClass)
.map(ClassUtils::getUserClass)
.distinct()
.filter(c -> packageToGroupId.keySet().stream().anyMatch(b -> c.getPackageName().startsWith(b)))
.filter(c -> getClassLocation(c).endsWith(".jar") || getClassLocation(c).endsWith(".jar!/"))
.forEach(c -> {
final var version = getVersion(c);
log.debug("Filtering dependency for javadoc '{}', version '{}'", c, version);
if (version != null) {
final var groupId = packageToGroupId.entrySet().stream().filter(e -> c.getPackageName().startsWith(e.getKey())).findFirst().get().getValue();
jarArtifacts.put(getClassLocation(c), Map.of("groupId", groupId, "version", version));
}
});
for (final var jarArtifact : jarArtifacts.entrySet()) {
final var artifact = jarArtifact.getValue();
final var version = artifact.get("version");
final var parts = StringUtils.split(StringUtils.splitByWholeSeparator(jarArtifact.getKey(), ".jar")[0], "/\\");
final var groupId = artifact.get("groupId");
final var artifactId = parts[parts.length - 1].split("-" + version)[0];
log.info("Installing javadoc based on file {}, version={}, artifact={}", jarArtifact.getKey(), version, artifactId);
install(null, groupId, artifactId, version, repository, false, true);
counter++;
}
response.put("updated", counter);
return response;
}
/**
* Install the specific version of given plug-in from the remote server. The previous version is not deleted. The
* downloaded version will be used only if it is a most recent version than the locally ones.
*
* @param artifact The Maven artifact identifier and also corresponding to the plug-in simple name.
* @param version The version to install.
* @param javadoc When true, the Javadoc is also installed and will contribute to OpenAPI documentation.
* @param repository The repository identifier to query.
*/
@POST
@Path("{artifact:[\\w-]+}/{version:[\\w\\.-]+}")
public void install(@PathParam("artifact") final String artifact, @PathParam("version") final String version,
@QueryParam("repository") @DefaultValue(REPO_CENTRAL) final String repository,
@QueryParam("javadoc") @DefaultValue("true") final boolean javadoc) {
install(null, getPluginGroupId(), artifact, version, repository, true, javadoc);
}
private String getPluginGroupId() {
return configuration.get(PLUGIN_GROUP_ID, DEFAULT_PLUGIN_GROUP_ID);
}
void install(final InputStream input, final String groupId, final String artifact, final String version,
final String repository, final boolean installPlugin, final boolean installJavadoc) {
final var classLoader = getPluginClassLoader();
if (installPlugin) {
final var target = classLoader.getPluginDirectory().resolve(artifact + "-" + version + ".jar");
log.info("Download plug-in {}:{} v{} from {}", groupId, artifact, version, repository);
try {
// Get the right input
final var input2 = input == null
? getRepositoryManager(repository).getArtifactInputStream(groupId, artifact, version, null)
: input;
// Download and copy the file, note the previous version is not removed
Files.copy(input2, target, StandardCopyOption.REPLACE_EXISTING);
log.info("Plugin {}:{} v{} has been installed in {}, restart is required", groupId, artifact, version, target);
} catch (final Exception ioe) {
// Installation failed, either download, either FS error
log.info("Unable to install plugin {}:{} v{} from {}", groupId, artifact, version, repository, ioe);
throw new ValidationJsonException("artifact", "cannot-be-installed", "id", artifact);
}
}
if (installJavadoc && input == null) {
log.info("Download Javadoc {}:{} v{} from {}", groupId, artifact, version, repository);
try {
final var javadocInput = getRepositoryManager(repository).getArtifactInputStream(groupId, artifact, version, "javadoc");
final var javadocTarget = classLoader.getPluginDirectory().resolve(artifact + "-" + version + "-javadoc.jar");
Files.copy(javadocInput, javadocTarget, StandardCopyOption.REPLACE_EXISTING);
log.info("Javadoc {}:{} v{} has been installed in {}, restart is required", groupId, artifact, version, javadocTarget);
} catch (IOException ioe) {
log.warn("Unable to install Javadoc {}:{} v{} from {}, non-blocking error", groupId, artifact, version, repository, ioe);
}
}
}
private Map getLastPluginVersions(final String repository) throws IOException {
final var versions = getRepositoryManager(repository).getLastPluginVersions();
// Remove ignored plug-ins
Arrays.stream(configuration.get(PLUGIN_IGNORE, "").split(",")).map(String::trim).forEach(versions::remove);
return versions;
}
/**
* Return the current plug-in class loader.
*
* @return The current plug-in class loader.
*/
protected PluginsClassLoader getPluginClassLoader() {
return PluginsClassLoader.getInstance();
}
/**
* Handle the newly installed plug-ins implementing {@link FeaturePlugin}. Note the plug-ins are installed in a
* natural order based on their key's name to ensure the parents plug-ins are configured first.
* Note the transactional behavior of this process : if one plug-in failed to be configured, then the entire process
* is cancelled. The previously plug-ins and the not processed discovered one are not configured.
*
* @param event The Spring event.
* @throws Exception When the context can not be refreshed because of plug-in updates or configurations.
*/
@EventListener
public void refreshPlugins(final ContextRefreshedEvent event) throws Exception {
// Auto install plug-ins
final var install = Arrays.stream(configuration.get(PLUGIN_INSTALL, "").split(",")).map(StringUtils::trimToNull)
.filter(Objects::nonNull).collect(Collectors.toSet());
var counter = install.isEmpty() ? 0 : autoInstall(install);
// Auto update plug-ins
if (BooleanUtils.toBoolean(configuration.get(PLUGIN_UPDATE, "false"))) {
// Update the plug-ins
counter += autoUpdate();
}
if (counter > 0) {
log.info("{} plug-ins have been updated/installed, context will be restarted", counter);
restart();
return;
}
log.info("No plug-ins have been automatically downloaded for update");
refreshPlugins(event.getApplicationContext());
}
private void refreshPlugins(final ApplicationContext context) throws Exception {
// Get the existing plug-in features
final var plugins = repository.findAll().stream()
.collect(Collectors.toMap(SystemPlugin::getKey, Function.identity()));
// Changes, order by the related feature's key
final var newFeatures = new TreeMap();
final var updateFeatures = new TreeMap();
final var removedPlugins = new HashSet<>(plugins.values());
// Compare with the available plug-in implementing ServicePlugin
context.getBeansOfType(FeaturePlugin.class).values().forEach(s -> {
final var plugin = plugins.get(s.getKey());
if (plugin == null) {
// New plug-in case
newFeatures.put(s.getKey(), s);
} else {
// Update the artifactId. May have not changed
plugin.setArtifact(toArtifactId(s));
if (isAnUpdate(plugin, s)) {
// The version is different, consider it as an update
updateFeatures.put(s.getKey(), s);
}
// This plug-in has just been handled, so not removed
removedPlugins.remove(plugin);
}
});
// First install the data of new plug-ins
updateFeatures.values().forEach(s -> configurePluginUpdate(s, plugins.get(s.getKey())));
newFeatures.values().forEach(this::configurePluginInstall);
// Then install/update the plug-in
update(updateFeatures, plugins);
installInternal(newFeatures);
log.info("Plugins are now configured");
// And remove the old plug-in no more installed
repository.deleteAll(removedPlugins.stream().map(Persistable::getId).toList());
}
/**
* Return true if the feature is a state requiring an update.
*
* @param plugin The previous state of the plugin.
* @param featurePlugin The loaded plugin class.
* @return true if the feature is a state requiring an update.
*/
boolean isAnUpdate(SystemPlugin plugin, FeaturePlugin featurePlugin) {
return !plugin.getVersion().equals(getVersion(featurePlugin))
|| !featurePlugin.getClass().getPackageName().equals(plugin.getBasePackage());
}
/**
* Auto install the required plug-ins.
*
* @param plugins The plug-ins to install.
* @return The amount of updated plug-ins.
* @throws IOException When plug-ins cannot be updated.
*/
public int autoInstall(final Set plugins) throws IOException {
final var currentPlugins = getPluginClassLoader().getInstalledPlugins();
final var withJavaDoc = BooleanUtils.toBoolean(configuration.get(PLUGIN_INSTALL_JAVADOC, "true"));
final var repositoryName = configuration.get(PLUGIN_REPOSITORY, REPO_CENTRAL);
var counter = 0;
for (final var artifact : getLastPluginVersions(repositoryName).values().stream().map(Artifact::getArtifact)
.filter(plugins::contains).filter(Predicate.not(currentPlugins::containsKey)).toList()) {
install(artifact, repositoryName, withJavaDoc);
counter++;
}
return counter;
}
/**
* Auto update the installed plug-ins.
*
* @return The amount of updated plug-ins.
* @throws IOException When plug-ins cannot be updated.
*/
public int autoUpdate() throws IOException {
final var plugins = getPluginClassLoader().getInstalledPlugins();
final var repositoryName = configuration.get(PLUGIN_REPOSITORY, REPO_CENTRAL);
var counter = 0;
for (final var artifact : getLastPluginVersions(repositoryName).values().stream()
.filter(a -> plugins.containsKey(a.getArtifact()))
.filter(a -> PluginsClassLoader.toExtendedVersion(a.getVersion())
.compareTo(StringUtils.removeStart(plugins.get(a.getArtifact()), a.getArtifact() + "-")) > 0)
.toList()) {
install(artifact.getArtifact(), repositoryName, true);
counter++;
}
return counter;
}
/**
* Install all ordered plug-ins.
*/
private void installInternal(final Map newFeatures) throws Exception {
for (final var feature : newFeatures.values()) {
// Do not trigger the installation event when corresponding node is already there
if (context.getBeansOfType(PluginListener.class).values().stream().allMatch(l -> l.install(feature))) {
feature.install();
}
}
}
/**
* Update all ordered plug-ins.
*/
private void update(final Map updateFeatures, final Map plugins)
throws Exception {
for (var feature : updateFeatures.entrySet()) {
feature.getValue().update(plugins.get(feature.getKey()).getVersion());
}
}
/**
* Returns a plug-in's last modified time.
*
* @param plugin The plug-in class. Will be used to find the related container archive or class file.
* @return a {@code String} representing the time the file was last modified, or a default time stamp to indicate
* the time of last modification is not supported by the file system
* @throws URISyntaxException if an I/O error occurs
* @throws IOException if an I/O error occurs
*/
protected String getLastModifiedTime(final FeaturePlugin plugin) throws IOException, URISyntaxException {
return Files
.getLastModifiedTime(
Paths.get(plugin.getClass().getProtectionDomain().getCodeSource().getLocation().toURI()))
.toString();
}
/**
* Configure the updated plug-in in this order :
*
* - The required entities for the plug-in are persisted. These entities are discovered from
* {@link FeaturePlugin#getInstalledEntities()} and related CSV files are load in the data base.
* - The entity {@link SystemPlugin} is updated to reflect the new version.
*
*
* @param plugin The newly updated plug-in.
* @param entity The current plug-in entity to update.
*/
protected void configurePluginUpdate(final FeaturePlugin plugin, final SystemPlugin entity) {
final var newVersion = getVersion(plugin);
log.info("Updating the plugin {} v{} -> v{}", plugin.getKey(), entity.getVersion(), newVersion);
entity.setVersion(newVersion);
entity.setBasePackage(plugin.getClass().getPackageName());
// Configure the plug-in entities even for already install plugins
try {
configurePluginEntities(plugin, plugin.getInstalledEntities());
} catch (final Exception e) { // NOSONAR - Catch all to notice every time the failure
// Something happened
log.error("Post-configuration of installed plugin {} v{} failed but is not a blocker since in the update flow: {}", plugin.getKey(), newVersion, e.getMessage());
}
}
/**
* Configure the new plug-in in this order :
*
* - The required entities for the plug-in are persisted. These entities are discovered from
* {@link FeaturePlugin#getInstalledEntities()} and related CSV files are load in the data base.
* - A new {@link SystemPlugin} is inserted to maintain the validated plug-in and version
*
*
* @param plugin The newly discovered plug-in.
*/
protected void configurePluginInstall(final FeaturePlugin plugin) {
final var newVersion = getVersion(plugin);
log.info("Installing the new plugin {} v{}", plugin.getKey(), newVersion);
try {
// Build and persist the SystemPlugin entity
final var entity = new SystemPlugin();
entity.setArtifact(toArtifactId(plugin));
entity.setKey(plugin.getKey());
entity.setVersion(newVersion);
entity.setBasePackage(plugin.getClass().getPackageName());
entity.setType("FEATURE");
context.getBeansOfType(PluginListener.class).values().forEach(l -> l.configure(plugin, entity));
repository.saveAndFlush(entity);
// Configure the plug-in entities
configurePluginEntities(plugin, plugin.getInstalledEntities());
} catch (final Exception e) { // NOSONAR - Catch all to notice every time the failure
// Something happened
log.error("Installing the new plugin {} v{} failed", plugin.getKey(), newVersion, e);
throw new TechnicalException(String.format("Configuring the new plugin %s failed", plugin.getKey()), e);
}
}
/**
* Guess the Maven artifactId from plug-in artifact name. Use the key and replace the "service" or "feature" part by
* "plugin".
*
* @param plugin The plugin class.
* @return The Maven "artifactId" as it should be when naming convention is respected. Required to detect the new
* version.
*/
public String toArtifactId(final FeaturePlugin plugin) {
return "plugin-" + Arrays.stream(plugin.getKey().split(":")).skip(1).collect(Collectors.joining("-"));
}
/**
* Insert the configuration entities of the plug-in. This function can be called multiple times : a check prevent
* duplicate entries.
*
* @param plugin The related plug-in
* @param csvEntities The managed entities where CSV data need to be persisted with this plug-in.
* @throws IOException When the CSV management failed.
*/
protected void configurePluginEntities(final FeaturePlugin plugin, final List> csvEntities)
throws IOException {
//
final var classLoader = plugin.getClass().getClassLoader();
// Compute the location of this plug-in, ensuring the
final var pluginLocation = getPluginLocation(plugin).toString();
for (final var entityClass : csvEntities) {
// Build the required CSV file
final var csv = "csv/"
+ String.join("-", StringUtils.splitByCharacterTypeCamelCase(entityClass.getSimpleName()))
.toLowerCase(Locale.ENGLISH)
+ ".csv";
configurePluginEntity(Collections.list(classLoader.getResources(csv)).stream(), entityClass,
pluginLocation);
}
}
/**
* Return the file system location corresponding to the given plug-in.
*
* @param plugin The related plug-in
* @return The URL corresponding to the location.
*/
protected URL getPluginLocation(final FeaturePlugin plugin) {
return plugin.getClass().getProtectionDomain().getCodeSource().getLocation();
}
/**
* Configure a plug-in from a given class and CSV. Its content is converted into target entity's type and inserted
* with JPA. The CSV file must be located inside the scope of the target plug-in.
*
* @param Target class type.
* @param csv List of URL of CSV. Only the first one matching to plug-in location is read.
* @param entityClass Target class of the CSV.
* @param pluginLocation The plug-in owner's location
* @throws IOException When the CSV management failed.
*/
protected void configurePluginEntity(final Stream csv, final Class entityClass,
final String pluginLocation) throws IOException {
// Accept the CSV file only from the JAR/folder where the plug-in is installed from
try (var input = new InputStreamReader(
csv.filter(u -> u.getPath().startsWith(pluginLocation) || u.toString().startsWith(pluginLocation))
.findFirst()
.orElseThrow(() -> new TechnicalException(
String.format("Unable to find CSV file for entity %s", entityClass.getSimpleName())))
.openStream(),
StandardCharsets.UTF_8)) {
// Build and save the entities managed by this plug-in
csvForJpa.toJpa(entityClass, input, true, false, e -> {
// Need brace because of ambiguous signature consumer/Function
persistAsNeeded(entityClass, e);
});
em.flush();
em.clear();
}
}
/**
* Persist the given entity only if it is not yet persisted. This is not an update mode.
*
* @param entityClass The entity class to persist.
* @param entity The entity read from the CSV, and to persist.
* @param The entity type.
*/
protected void persistAsNeeded(final Class entityClass, T entity) {
switch (entity) {
case AbstractBusinessEntity> be -> persistAsNeeded(entityClass, be);
case AbstractStringKeyEntity se -> persistAsNeeded(entityClass, se);
case INamableBean> nb -> persistAsNeeded(entityClass, entity, "name", nb.getName());
case SystemUser su -> persistAsNeeded(entityClass, entity, "login", su.getLogin());
case null, default -> em.persist(entity);
}
}
private void persistAsNeeded(final Class entityClass, Persistable> entity) {
// Check for duplicate before the insert
if (em.find(entityClass, entity.getId()) == null) {
em.persist(entity);
}
}
private void persistAsNeeded(final Class entityClass, Object entity, final String property,
final String value) {
if (em.createQuery("SELECT 1 FROM " + entityClass.getName() + " WHERE " + property + " = :value")
.setParameter("value", value).getResultList().isEmpty()) {
em.persist(entity);
}
}
/**
* Return a fail-safe computed version of the given {@link FeaturePlugin}
*
* @param plugin The plug-in instance
* @return The version from the MANIFEST or the timestamp. ?
when an error occurs.
*/
protected String getVersion(final FeaturePlugin plugin) {
return Optional.ofNullable(plugin.getVersion()).orElseGet(() -> {
// Not explicit version
try {
return getLastModifiedTime(plugin);
} catch (final IOException | URISyntaxException e) {
log.warn("Unable to determine the version of plug-in {}", plugin.getClass(), e);
// Fail-safe version
return "?";
}
});
}
@Override
public void decorate(final SessionSettings settings) {
// Add the enabled plug-ins
if (settings.getApplicationSettings().getPlugins() == null) {
settings.getApplicationSettings().setPlugins(context.getBeansOfType(FeaturePlugin.class).values().stream()
.map(FeaturePlugin::getKey).toList());
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy