diff options
Diffstat (limited to 'java/com/google/gerrit/server/plugins/PluginLoader.java')
-rw-r--r-- | java/com/google/gerrit/server/plugins/PluginLoader.java | 732 |
1 files changed, 732 insertions, 0 deletions
diff --git a/java/com/google/gerrit/server/plugins/PluginLoader.java b/java/com/google/gerrit/server/plugins/PluginLoader.java new file mode 100644 index 0000000000..57e7e4993f --- /dev/null +++ b/java/com/google/gerrit/server/plugins/PluginLoader.java @@ -0,0 +1,732 @@ +// Copyright (C) 2012 The Android Open Source Project +// +// Licensed 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 com.google.gerrit.server.plugins; + +import com.google.common.base.CharMatcher; +import com.google.common.base.Joiner; +import com.google.common.base.MoreObjects; +import com.google.common.base.Strings; +import com.google.common.collect.ComparisonChain; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.google.common.collect.LinkedHashMultimap; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.collect.SetMultimap; +import com.google.common.collect.Sets; +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.extensions.events.LifecycleListener; +import com.google.gerrit.extensions.restapi.MethodNotAllowedException; +import com.google.gerrit.extensions.systemstatus.ServerInformation; +import com.google.gerrit.server.PluginUser; +import com.google.gerrit.server.cache.PersistentCacheFactory; +import com.google.gerrit.server.config.CanonicalWebUrl; +import com.google.gerrit.server.config.ConfigUtil; +import com.google.gerrit.server.config.GerritRuntime; +import com.google.gerrit.server.config.GerritServerConfig; +import com.google.gerrit.server.config.SitePaths; +import com.google.gerrit.server.plugins.ServerPluginProvider.PluginDescription; +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.Singleton; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.AbstractMap; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Queue; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; +import org.eclipse.jgit.internal.storage.file.FileSnapshot; +import org.eclipse.jgit.lib.Config; + +@Singleton +public class PluginLoader implements LifecycleListener { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + public String getPluginName(Path srcPath) { + return MoreObjects.firstNonNull(getGerritPluginName(srcPath), PluginUtil.nameOf(srcPath)); + } + + private final Path pluginsDir; + private final Path dataDir; + private final Path tempDir; + private final PluginGuiceEnvironment env; + private final ServerInformationImpl srvInfoImpl; + private final PluginUser.Factory pluginUserFactory; + private final ConcurrentMap<String, Plugin> running = Maps.newConcurrentMap(); + private final ConcurrentMap<String, Plugin> disabled = Maps.newConcurrentMap(); + private final Map<String, FileSnapshot> broken = Maps.newHashMap(); + private final Map<Plugin, CleanupHandle> cleanupHandles = Maps.newConcurrentMap(); + private final Queue<Plugin> toCleanup = new ArrayDeque<>(); + private final Provider<PluginCleanerTask> cleaner; + private final PluginScannerThread scanner; + private final Provider<String> urlProvider; + private final PersistentCacheFactory persistentCacheFactory; + private final boolean remoteAdmin; + private final UniversalServerPluginProvider serverPluginFactory; + private final GerritRuntime gerritRuntime; + + @Inject + public PluginLoader( + SitePaths sitePaths, + PluginGuiceEnvironment pe, + ServerInformationImpl sii, + PluginUser.Factory puf, + Provider<PluginCleanerTask> pct, + @GerritServerConfig Config cfg, + @CanonicalWebUrl Provider<String> provider, + PersistentCacheFactory cacheFactory, + UniversalServerPluginProvider pluginFactory, + GerritRuntime gerritRuntime) { + pluginsDir = sitePaths.plugins_dir; + dataDir = sitePaths.data_dir; + tempDir = sitePaths.tmp_dir; + env = pe; + srvInfoImpl = sii; + pluginUserFactory = puf; + cleaner = pct; + urlProvider = provider; + persistentCacheFactory = cacheFactory; + serverPluginFactory = pluginFactory; + + remoteAdmin = cfg.getBoolean("plugins", null, "allowRemoteAdmin", false); + this.gerritRuntime = gerritRuntime; + + long checkFrequency = + ConfigUtil.getTimeUnit( + cfg, + "plugins", + null, + "checkFrequency", + TimeUnit.MINUTES.toMillis(1), + TimeUnit.MILLISECONDS); + if (checkFrequency > 0) { + scanner = new PluginScannerThread(this, checkFrequency); + } else { + scanner = null; + } + } + + public boolean isRemoteAdminEnabled() { + return remoteAdmin; + } + + public void checkRemoteAdminEnabled() throws MethodNotAllowedException { + if (!remoteAdmin) { + throw new MethodNotAllowedException("remote plugin administration is disabled"); + } + } + + public Plugin get(String name) { + Plugin p = running.get(name); + if (p != null) { + return p; + } + return disabled.get(name); + } + + public Iterable<Plugin> getPlugins(boolean all) { + if (!all) { + return running.values(); + } + List<Plugin> plugins = new ArrayList<>(running.values()); + plugins.addAll(disabled.values()); + return plugins; + } + + public String installPluginFromStream(String originalName, InputStream in) + throws IOException, PluginInstallException { + checkRemoteInstall(); + + String fileName = originalName; + Path tmp = PluginUtil.asTemp(in, ".next_" + fileName + "_", ".tmp", pluginsDir); + String name = MoreObjects.firstNonNull(getGerritPluginName(tmp), PluginUtil.nameOf(fileName)); + if (!originalName.equals(name)) { + logger.atWarning().log( + "Plugin provides its own name: <%s>, use it instead of the input name: <%s>", + name, originalName); + } + + String fileExtension = getExtension(fileName); + Path dst = pluginsDir.resolve(name + fileExtension); + synchronized (this) { + Plugin active = running.get(name); + if (active != null) { + fileName = active.getSrcFile().getFileName().toString(); + logger.atInfo().log("Replacing plugin %s", active.getName()); + Path old = pluginsDir.resolve(".last_" + fileName); + Files.deleteIfExists(old); + Files.move(active.getSrcFile(), old); + } + + Files.deleteIfExists(pluginsDir.resolve(fileName + ".disabled")); + Files.move(tmp, dst); + try { + Plugin plugin = runPlugin(name, dst, active); + if (active == null) { + logger.atInfo().log("Installed plugin %s", plugin.getName()); + } + } catch (PluginInstallException e) { + Files.deleteIfExists(dst); + throw e; + } + + cleanInBackground(); + } + + return name; + } + + private synchronized void unloadPlugin(Plugin plugin) { + persistentCacheFactory.onStop(plugin.getName()); + String name = plugin.getName(); + logger.atInfo().log("Unloading plugin %s, version %s", name, plugin.getVersion()); + plugin.stop(env); + env.onStopPlugin(plugin); + running.remove(name); + disabled.remove(name); + toCleanup.add(plugin); + } + + public void disablePlugins(Set<String> names) { + if (!isRemoteAdminEnabled()) { + logger.atWarning().log( + "Remote plugin administration is disabled, ignoring disablePlugins(%s)", names); + return; + } + + synchronized (this) { + for (String name : names) { + Plugin active = running.get(name); + if (active == null) { + continue; + } + + logger.atInfo().log("Disabling plugin %s", active.getName()); + Path off = + active.getSrcFile().resolveSibling(active.getSrcFile().getFileName() + ".disabled"); + try { + Files.move(active.getSrcFile(), off); + } catch (IOException e) { + logger.atSevere().withCause(e).log("Failed to disable plugin"); + // In theory we could still unload the plugin even if the rename + // failed. However, it would be reloaded on the next server startup, + // which is probably not what the user expects. + continue; + } + + unloadPlugin(active); + try { + FileSnapshot snapshot = FileSnapshot.save(off.toFile()); + Plugin offPlugin = loadPlugin(name, off, snapshot); + disabled.put(name, offPlugin); + } catch (Throwable e) { + // This shouldn't happen, as the plugin was loaded earlier. + logger.atWarning().withCause(e.getCause()).log( + "Cannot load disabled plugin %s", active.getName()); + } + } + cleanInBackground(); + } + } + + public void enablePlugins(Set<String> names) throws PluginInstallException { + if (!isRemoteAdminEnabled()) { + logger.atWarning().log( + "Remote plugin administration is disabled, ignoring enablePlugins(%s)", names); + return; + } + + synchronized (this) { + for (String name : names) { + Plugin off = disabled.get(name); + if (off == null) { + continue; + } + + logger.atInfo().log("Enabling plugin %s", name); + String n = off.getSrcFile().toFile().getName(); + if (n.endsWith(".disabled")) { + n = n.substring(0, n.lastIndexOf('.')); + } + Path on = pluginsDir.resolve(n); + try { + Files.move(off.getSrcFile(), on); + } catch (IOException e) { + logger.atSevere().withCause(e).log("Failed to move plugin %s into place", name); + continue; + } + disabled.remove(name); + runPlugin(name, on, null); + } + cleanInBackground(); + } + } + + private void removeStalePluginFiles() { + DirectoryStream.Filter<Path> filter = + new DirectoryStream.Filter<Path>() { + @Override + public boolean accept(Path entry) throws IOException { + return entry.getFileName().toString().startsWith("plugin_"); + } + }; + try (DirectoryStream<Path> files = Files.newDirectoryStream(tempDir, filter)) { + for (Path file : files) { + logger.atInfo().log("Removing stale plugin file: %s", file.toFile().getName()); + try { + Files.delete(file); + } catch (IOException e) { + logger.atSevere().log( + "Failed to remove stale plugin file %s: %s", file.toFile().getName(), e.getMessage()); + } + } + } catch (IOException e) { + logger.atWarning().log("Unable to discover stale plugin files: %s", e.getMessage()); + } + } + + @Override + public synchronized void start() { + removeStalePluginFiles(); + Path absolutePath = pluginsDir.toAbsolutePath(); + if (!Files.exists(absolutePath)) { + logger.atInfo().log("%s does not exist; creating", absolutePath); + try { + Files.createDirectories(absolutePath); + } catch (IOException e) { + logger.atSevere().log("Failed to create %s: %s", absolutePath, e.getMessage()); + } + } + logger.atInfo().log("Loading plugins from %s", absolutePath); + srvInfoImpl.state = ServerInformation.State.STARTUP; + rescan(); + srvInfoImpl.state = ServerInformation.State.RUNNING; + if (scanner != null) { + scanner.start(); + } + } + + @Override + public void stop() { + if (scanner != null) { + scanner.end(); + } + srvInfoImpl.state = ServerInformation.State.SHUTDOWN; + synchronized (this) { + for (Plugin p : running.values()) { + unloadPlugin(p); + } + running.clear(); + disabled.clear(); + broken.clear(); + if (!toCleanup.isEmpty()) { + System.gc(); + processPendingCleanups(); + } + } + } + + public void reload(List<String> names) throws InvalidPluginException, PluginInstallException { + synchronized (this) { + List<Plugin> reload = Lists.newArrayListWithCapacity(names.size()); + List<String> bad = Lists.newArrayListWithExpectedSize(4); + for (String name : names) { + Plugin active = running.get(name); + if (active != null) { + reload.add(active); + } else { + bad.add(name); + } + } + if (!bad.isEmpty()) { + throw new InvalidPluginException( + String.format("Plugin(s) \"%s\" not running", Joiner.on("\", \"").join(bad))); + } + + for (Plugin active : reload) { + String name = active.getName(); + try { + logger.atInfo().log("Reloading plugin %s", name); + Plugin newPlugin = runPlugin(name, active.getSrcFile(), active); + logger.atInfo().log( + "Reloaded plugin %s, version %s", newPlugin.getName(), newPlugin.getVersion()); + } catch (PluginInstallException e) { + logger.atWarning().withCause(e.getCause()).log("Cannot reload plugin %s", name); + throw e; + } + } + + cleanInBackground(); + } + } + + public synchronized void rescan() { + SetMultimap<String, Path> pluginsFiles = prunePlugins(pluginsDir); + if (pluginsFiles.isEmpty()) { + return; + } + + syncDisabledPlugins(pluginsFiles); + + Map<String, Path> activePlugins = filterDisabled(pluginsFiles); + for (Map.Entry<String, Path> entry : jarsFirstSortedPluginsSet(activePlugins)) { + String name = entry.getKey(); + Path path = entry.getValue(); + String fileName = path.getFileName().toString(); + if (!isUiPlugin(fileName) && !serverPluginFactory.handles(path)) { + logger.atWarning().log( + "No Plugin provider was found that handles this file format: %s", fileName); + continue; + } + + FileSnapshot brokenTime = broken.get(name); + if (brokenTime != null && !brokenTime.isModified(path.toFile())) { + continue; + } + + Plugin active = running.get(name); + if (active != null && !active.isModified(path)) { + continue; + } + + if (active != null) { + logger.atInfo().log("Reloading plugin %s", active.getName()); + } + + try { + Plugin loadedPlugin = runPlugin(name, path, active); + if (!loadedPlugin.isDisabled()) { + logger.atInfo().log( + "%s plugin %s, version %s", + active == null ? "Loaded" : "Reloaded", + loadedPlugin.getName(), + loadedPlugin.getVersion()); + } + } catch (PluginInstallException e) { + logger.atWarning().withCause(e.getCause()).log("Cannot load plugin %s", name); + } + } + + cleanInBackground(); + } + + private void addAllEntries(Map<String, Path> from, TreeSet<Entry<String, Path>> to) { + Iterator<Entry<String, Path>> it = from.entrySet().iterator(); + while (it.hasNext()) { + Entry<String, Path> entry = it.next(); + to.add(new AbstractMap.SimpleImmutableEntry<>(entry.getKey(), entry.getValue())); + } + } + + private TreeSet<Entry<String, Path>> jarsFirstSortedPluginsSet(Map<String, Path> activePlugins) { + TreeSet<Entry<String, Path>> sortedPlugins = + Sets.newTreeSet( + new Comparator<Entry<String, Path>>() { + @Override + public int compare(Entry<String, Path> e1, Entry<String, Path> e2) { + Path n1 = e1.getValue().getFileName(); + Path n2 = e2.getValue().getFileName(); + return ComparisonChain.start() + .compareTrueFirst(isJar(n1), isJar(n2)) + .compare(n1, n2) + .result(); + } + + private boolean isJar(Path n1) { + return n1.toString().endsWith(".jar"); + } + }); + + addAllEntries(activePlugins, sortedPlugins); + return sortedPlugins; + } + + private void syncDisabledPlugins(SetMultimap<String, Path> jars) { + stopRemovedPlugins(jars); + dropRemovedDisabledPlugins(jars); + } + + private Plugin runPlugin(String name, Path plugin, Plugin oldPlugin) + throws PluginInstallException { + FileSnapshot snapshot = FileSnapshot.save(plugin.toFile()); + try { + Plugin newPlugin = loadPlugin(name, plugin, snapshot); + if (newPlugin.getCleanupHandle() != null) { + cleanupHandles.put(newPlugin, newPlugin.getCleanupHandle()); + } + /* + * Pluggable plugin provider may have assigned a plugin name that could be + * actually different from the initial one assigned during scan. It is + * safer then to reassign it. + */ + name = newPlugin.getName(); + boolean reload = oldPlugin != null && oldPlugin.canReload() && newPlugin.canReload(); + if (!reload && oldPlugin != null) { + unloadPlugin(oldPlugin); + } + if (!newPlugin.isDisabled()) { + try { + newPlugin.start(env); + } catch (Throwable e) { + newPlugin.stop(env); + throw e; + } + } + if (reload) { + env.onReloadPlugin(oldPlugin, newPlugin); + unloadPlugin(oldPlugin); + } else if (!newPlugin.isDisabled()) { + env.onStartPlugin(newPlugin); + } + if (!newPlugin.isDisabled()) { + running.put(name, newPlugin); + } else { + disabled.put(name, newPlugin); + } + broken.remove(name); + return newPlugin; + } catch (Throwable err) { + broken.put(name, snapshot); + throw new PluginInstallException(err); + } + } + + private void stopRemovedPlugins(SetMultimap<String, Path> jars) { + Set<String> unload = Sets.newHashSet(running.keySet()); + for (Map.Entry<String, Collection<Path>> entry : jars.asMap().entrySet()) { + for (Path path : entry.getValue()) { + if (!path.getFileName().toString().endsWith(".disabled")) { + unload.remove(entry.getKey()); + } + } + } + for (String name : unload) { + unloadPlugin(running.get(name)); + } + } + + private void dropRemovedDisabledPlugins(SetMultimap<String, Path> jars) { + Set<String> unload = Sets.newHashSet(disabled.keySet()); + for (Map.Entry<String, Collection<Path>> entry : jars.asMap().entrySet()) { + for (Path path : entry.getValue()) { + if (path.getFileName().toString().endsWith(".disabled")) { + unload.remove(entry.getKey()); + } + } + } + for (String name : unload) { + disabled.remove(name); + } + } + + synchronized int processPendingCleanups() { + Iterator<Plugin> iterator = toCleanup.iterator(); + while (iterator.hasNext()) { + Plugin plugin = iterator.next(); + iterator.remove(); + + CleanupHandle cleanupHandle = cleanupHandles.remove(plugin); + if (cleanupHandle != null) { + cleanupHandle.cleanup(); + } + } + return toCleanup.size(); + } + + private void cleanInBackground() { + int cnt = toCleanup.size(); + if (0 < cnt) { + cleaner.get().clean(cnt); + } + } + + private String getExtension(String name) { + int ext = name.lastIndexOf('.'); + return 0 < ext ? name.substring(ext) : ""; + } + + private Plugin loadPlugin(String name, Path srcPlugin, FileSnapshot snapshot) + throws InvalidPluginException { + String pluginName = srcPlugin.getFileName().toString(); + if (isUiPlugin(pluginName)) { + return loadJsPlugin(name, srcPlugin, snapshot); + } else if (serverPluginFactory.handles(srcPlugin)) { + return loadServerPlugin(srcPlugin, snapshot); + } else { + throw new InvalidPluginException( + String.format("Unsupported plugin type: %s", srcPlugin.getFileName())); + } + } + + private Path getPluginDataDir(String name) { + return dataDir.resolve(name); + } + + private String getPluginCanonicalWebUrl(String name) { + String canonicalWebUrl = urlProvider.get(); + if (Strings.isNullOrEmpty(canonicalWebUrl)) { + return "/plugins/" + name; + } + + String url = + String.format( + "%s/plugins/%s/", CharMatcher.is('/').trimTrailingFrom(canonicalWebUrl), name); + return url; + } + + private Plugin loadJsPlugin(String name, Path srcJar, FileSnapshot snapshot) { + return new JsPlugin(name, srcJar, pluginUserFactory.create(name), snapshot); + } + + private ServerPlugin loadServerPlugin(Path scriptFile, FileSnapshot snapshot) + throws InvalidPluginException { + String name = serverPluginFactory.getPluginName(scriptFile); + return serverPluginFactory.get( + scriptFile, + snapshot, + new PluginDescription( + pluginUserFactory.create(name), + getPluginCanonicalWebUrl(name), + getPluginDataDir(name), + gerritRuntime)); + } + + // Only one active plugin per plugin name can exist for each plugin name. + // Filter out disabled plugins and transform the multimap to a map + private Map<String, Path> filterDisabled(SetMultimap<String, Path> pluginPaths) { + Map<String, Path> activePlugins = Maps.newHashMapWithExpectedSize(pluginPaths.keys().size()); + for (String name : pluginPaths.keys()) { + for (Path pluginPath : pluginPaths.asMap().get(name)) { + if (!pluginPath.getFileName().toString().endsWith(".disabled")) { + assert !activePlugins.containsKey(name); + activePlugins.put(name, pluginPath); + } + } + } + return activePlugins; + } + + // Scan the $site_path/plugins directory and fetch all files and directories. + // The Key in returned multimap is the plugin name initially assigned from its filename. + // Values are the files. Plugins can optionally provide their name in MANIFEST file. + // If multiple plugin files provide the same plugin name, then only + // the first plugin remains active and all other plugins with the same + // name are disabled. + // + // NOTE: Bear in mind that the plugin name can be reassigned after load by the + // Server plugin provider. + public SetMultimap<String, Path> prunePlugins(Path pluginsDir) { + List<Path> pluginPaths = scanPathsInPluginsDirectory(pluginsDir); + SetMultimap<String, Path> map; + map = asMultimap(pluginPaths); + for (String plugin : map.keySet()) { + Collection<Path> files = map.asMap().get(plugin); + if (files.size() == 1) { + continue; + } + // retrieve enabled plugins + Iterable<Path> enabled = filterDisabledPlugins(files); + // If we have only one (the winner) plugin, nothing to do + if (!Iterables.skip(enabled, 1).iterator().hasNext()) { + continue; + } + Path winner = Iterables.getFirst(enabled, null); + assert winner != null; + // Disable all loser plugins by renaming their file names to + // "file.disabled" and replace the disabled files in the multimap. + Collection<Path> elementsToRemove = new ArrayList<>(); + Collection<Path> elementsToAdd = new ArrayList<>(); + for (Path loser : Iterables.skip(enabled, 1)) { + logger.atWarning().log( + "Plugin <%s> was disabled, because" + + " another plugin <%s>" + + " with the same name <%s> already exists", + loser, winner, plugin); + Path disabledPlugin = Paths.get(loser + ".disabled"); + elementsToAdd.add(disabledPlugin); + elementsToRemove.add(loser); + try { + Files.move(loser, disabledPlugin); + } catch (IOException e) { + logger.atWarning().withCause(e).log("Failed to fully disable plugin %s", loser); + } + } + Iterables.removeAll(files, elementsToRemove); + Iterables.addAll(files, elementsToAdd); + } + return map; + } + + private List<Path> scanPathsInPluginsDirectory(Path pluginsDir) { + try { + return PluginUtil.listPlugins(pluginsDir); + } catch (IOException e) { + logger.atSevere().withCause(e).log("Cannot list %s", pluginsDir.toAbsolutePath()); + return ImmutableList.of(); + } + } + + private Iterable<Path> filterDisabledPlugins(Collection<Path> paths) { + return Iterables.filter(paths, p -> !p.getFileName().toString().endsWith(".disabled")); + } + + public String getGerritPluginName(Path srcPath) { + String fileName = srcPath.getFileName().toString(); + if (isUiPlugin(fileName)) { + return fileName.substring(0, fileName.lastIndexOf('.')); + } + if (serverPluginFactory.handles(srcPath)) { + return serverPluginFactory.getPluginName(srcPath); + } + return null; + } + + private SetMultimap<String, Path> asMultimap(List<Path> plugins) { + SetMultimap<String, Path> map = LinkedHashMultimap.create(); + for (Path srcPath : plugins) { + map.put(getPluginName(srcPath), srcPath); + } + return map; + } + + private boolean isUiPlugin(String name) { + return isPlugin(name, "js") || isPlugin(name, "html"); + } + + private boolean isPlugin(String fileName, String ext) { + String fullExt = "." + ext; + return fileName.endsWith(fullExt) || fileName.endsWith(fullExt + ".disabled"); + } + + private void checkRemoteInstall() throws PluginInstallException { + if (!isRemoteAdminEnabled()) { + throw new PluginInstallException("remote installation is disabled"); + } + } +} |