blob: b51359df8c6f99fb2a52f3d12619a936a21f87b1 [file] [log] [blame]
// 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.Predicate;
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.Multimap;
import com.google.common.collect.Queues;
import com.google.common.collect.Sets;
import com.google.gerrit.extensions.annotations.PluginName;
import com.google.gerrit.extensions.events.LifecycleListener;
import com.google.gerrit.extensions.systemstatus.ServerInformation;
import com.google.gerrit.extensions.webui.JavaScriptPlugin;
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.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 org.eclipse.jgit.internal.storage.file.FileSnapshot;
import org.eclipse.jgit.lib.Config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.FileFilter;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
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;
@Singleton
public class PluginLoader implements LifecycleListener {
static final String PLUGIN_TMP_PREFIX = "plugin_";
static final Logger log = LoggerFactory.getLogger(PluginLoader.class);
public String getPluginName(File srcFile) {
return MoreObjects.firstNonNull(getGerritPluginName(srcFile),
nameOf(srcFile));
}
private final File pluginsDir;
private final File dataDir;
private final PluginGuiceEnvironment env;
private final ServerInformationImpl srvInfoImpl;
private final PluginUser.Factory pluginUserFactory;
private final ConcurrentMap<String, Plugin> running;
private final ConcurrentMap<String, Plugin> disabled;
private final Map<String, FileSnapshot> broken;
private final Map<Plugin, CleanupHandle> cleanupHandles;
private final Queue<Plugin> toCleanup;
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;
@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) {
pluginsDir = sitePaths.plugins_dir;
dataDir = sitePaths.data_dir;
env = pe;
srvInfoImpl = sii;
pluginUserFactory = puf;
running = Maps.newConcurrentMap();
disabled = Maps.newConcurrentMap();
broken = Maps.newHashMap();
toCleanup = Queues.newArrayDeque();
cleanupHandles = Maps.newConcurrentMap();
cleaner = pct;
urlProvider = provider;
persistentCacheFactory = cacheFactory;
serverPluginFactory = pluginFactory;
remoteAdmin =
cfg.getBoolean("plugins", null, "allowRemoteAdmin", false);
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 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();
} else {
List<Plugin> plugins = new ArrayList<>(running.values());
plugins.addAll(disabled.values());
return plugins;
}
}
public void installPluginFromStream(String originalName, InputStream in)
throws IOException, PluginInstallException {
checkRemoteInstall();
String fileName = originalName;
File tmp = asTemp(in, ".next_" + fileName + "_", ".tmp", pluginsDir);
String name = MoreObjects.firstNonNull(getGerritPluginName(tmp),
nameOf(fileName));
if (!originalName.equals(name)) {
log.warn(String.format("Plugin provides its own name: <%s>,"
+ " use it instead of the input name: <%s>",
name, originalName));
}
String fileExtension = getExtension(fileName);
File dst = new File(pluginsDir, name + fileExtension);
synchronized (this) {
Plugin active = running.get(name);
if (active != null) {
fileName = active.getSrcFile().getName();
log.info(String.format("Replacing plugin %s", active.getName()));
File old = new File(pluginsDir, ".last_" + fileName);
old.delete();
active.getSrcFile().renameTo(old);
}
new File(pluginsDir, fileName + ".disabled").delete();
tmp.renameTo(dst);
try {
Plugin plugin = runPlugin(name, dst, active);
if (active == null) {
log.info(String.format("Installed plugin %s", plugin.getName()));
}
} catch (PluginInstallException e) {
dst.delete();
throw e;
}
cleanInBackground();
}
}
static File asTemp(InputStream in, String prefix, String suffix, File dir)
throws IOException {
File tmp = File.createTempFile(prefix, suffix, dir);
boolean keep = false;
try (FileOutputStream out = new FileOutputStream(tmp)) {
byte[] data = new byte[8192];
int n;
while ((n = in.read(data)) > 0) {
out.write(data, 0, n);
}
keep = true;
return tmp;
} finally {
if (!keep) {
tmp.delete();
}
}
}
private synchronized void unloadPlugin(Plugin plugin) {
persistentCacheFactory.onStop(plugin);
String name = plugin.getName();
log.info(String.format("Unloading plugin %s", name));
plugin.stop(env);
env.onStopPlugin(plugin);
running.remove(name);
disabled.remove(name);
toCleanup.add(plugin);
}
public void disablePlugins(Set<String> names) {
if (!isRemoteAdminEnabled()) {
log.warn("Remote plugin administration is disabled,"
+ " ignoring disablePlugins(" + names + ")");
return;
}
synchronized (this) {
for (String name : names) {
Plugin active = running.get(name);
if (active == null) {
continue;
}
log.info(String.format("Disabling plugin %s", active.getName()));
File off = new File(active.getSrcFile() + ".disabled");
active.getSrcFile().renameTo(off);
unloadPlugin(active);
try {
FileSnapshot snapshot = FileSnapshot.save(off);
Plugin offPlugin = loadPlugin(name, off, snapshot);
disabled.put(name, offPlugin);
} catch (Throwable e) {
// This shouldn't happen, as the plugin was loaded earlier.
log.warn(String.format(
"Cannot load disabled plugin %s", active.getName()),
e.getCause());
}
}
cleanInBackground();
}
}
public void enablePlugins(Set<String> names) throws PluginInstallException {
if (!isRemoteAdminEnabled()) {
log.warn("Remote plugin administration is disabled,"
+ " ignoring enablePlugins(" + names + ")");
return;
}
synchronized (this) {
for (String name : names) {
Plugin off = disabled.get(name);
if (off == null) {
continue;
}
log.info(String.format("Enabling plugin %s", name));
String n = off.getSrcFile().getName();
if (n.endsWith(".disabled")) {
n = n.substring(0, n.lastIndexOf('.'));
}
File on = new File(pluginsDir, n);
off.getSrcFile().renameTo(on);
disabled.remove(name);
runPlugin(name, on, null);
}
cleanInBackground();
}
}
@Override
public synchronized void start() {
log.info("Loading plugins from " + pluginsDir.getAbsolutePath());
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 {
log.info(String.format("Reloading plugin %s", name));
runPlugin(name, active.getSrcFile(), active);
} catch (PluginInstallException e) {
log.warn(String.format("Cannot reload plugin %s", name), e.getCause());
throw e;
}
}
cleanInBackground();
}
}
public synchronized void rescan() {
Multimap<String, File> pluginsFiles = prunePlugins(pluginsDir);
if (pluginsFiles.isEmpty()) {
return;
}
syncDisabledPlugins(pluginsFiles);
Map<String, File> activePlugins = filterDisabled(pluginsFiles);
for (Map.Entry<String, File> entry : jarsFirstSortedPluginsSet(activePlugins)) {
String name = entry.getKey();
File file = entry.getValue();
String fileName = file.getName();
if (!isJsPlugin(fileName) && !serverPluginFactory.handles(file)) {
log.warn("No Plugin provider was found that handles this file format: {}", fileName);
continue;
}
FileSnapshot brokenTime = broken.get(name);
if (brokenTime != null && !brokenTime.isModified(file)) {
continue;
}
Plugin active = running.get(name);
if (active != null && !active.isModified(file)) {
continue;
}
if (active != null) {
log.info(String.format("Reloading plugin %s, version %s",
active.getName(), active.getVersion()));
}
try {
Plugin loadedPlugin = runPlugin(name, file, active);
if (active == null && !loadedPlugin.isDisabled()) {
log.info(String.format("Loaded plugin %s, version %s",
loadedPlugin.getName(), loadedPlugin.getVersion()));
}
} catch (PluginInstallException e) {
log.warn(String.format("Cannot load plugin %s", name), e.getCause());
}
}
cleanInBackground();
}
private void addAllEntries(Map<String, File> from,
TreeSet<Entry<String, File>> to) {
Iterator<Entry<String, File>> it = from.entrySet().iterator();
while (it.hasNext()) {
Entry<String,File> entry = it.next();
to.add(new AbstractMap.SimpleImmutableEntry<>(
entry.getKey(), entry.getValue()));
}
}
private TreeSet<Entry<String, File>> jarsFirstSortedPluginsSet(
Map<String, File> activePlugins) {
TreeSet<Entry<String, File>> sortedPlugins =
Sets.newTreeSet(new Comparator<Entry<String, File>>() {
@Override
public int compare(Entry<String, File> entry1,
Entry<String, File> entry2) {
String file1 = entry1.getValue().getName();
String file2 = entry2.getValue().getName();
int cmp = file1.compareTo(file2);
if (file1.endsWith(".jar")) {
return (file2.endsWith(".jar") ? cmp : -1);
} else {
return (file2.endsWith(".jar") ? +1 : cmp);
}
}
});
addAllEntries(activePlugins, sortedPlugins);
return sortedPlugins;
}
private void syncDisabledPlugins(Multimap<String, File> jars) {
stopRemovedPlugins(jars);
dropRemovedDisabledPlugins(jars);
}
private Plugin runPlugin(String name, File plugin, Plugin oldPlugin)
throws PluginInstallException {
FileSnapshot snapshot = FileSnapshot.save(plugin);
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()) {
newPlugin.start(env);
}
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(Multimap<String, File> jars) {
Set<String> unload = Sets.newHashSet(running.keySet());
for (Map.Entry<String, Collection<File>> entry : jars.asMap().entrySet()) {
for (File file : entry.getValue()) {
if (!file.getName().endsWith(".disabled")) {
unload.remove(entry.getKey());
}
}
}
for (String name : unload) {
unloadPlugin(running.get(name));
}
}
private void dropRemovedDisabledPlugins(Multimap<String, File> jars) {
Set<String> unload = Sets.newHashSet(disabled.keySet());
for (Map.Entry<String, Collection<File>> entry : jars.asMap().entrySet()) {
for (File file : entry.getValue()) {
if (file.getName().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);
}
}
public static String nameOf(File plugin) {
return nameOf(plugin.getName());
}
private static String nameOf(String name) {
if (name.endsWith(".disabled")) {
name = name.substring(0, name.lastIndexOf('.'));
}
int ext = name.lastIndexOf('.');
return 0 < ext ? name.substring(0, ext) : name;
}
private static String getExtension(String name) {
int ext = name.lastIndexOf('.');
return 0 < ext ? name.substring(ext) : "";
}
private Plugin loadPlugin(String name, File srcPlugin, FileSnapshot snapshot)
throws InvalidPluginException {
String pluginName = srcPlugin.getName();
if (isJsPlugin(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.getName()));
}
}
private File getPluginDataDir(String name) {
return new File(dataDir, name);
}
private String getPluginCanonicalWebUrl(String name) {
String url = String.format("%s/plugins/%s/",
CharMatcher.is('/').trimTrailingFrom(urlProvider.get()),
name);
return url;
}
private Plugin loadJsPlugin(String name, File srcJar, FileSnapshot snapshot) {
return new JsPlugin(name, srcJar, pluginUserFactory.create(name), snapshot);
}
private ServerPlugin loadServerPlugin(File scriptFile,
FileSnapshot snapshot) throws InvalidPluginException {
String name = serverPluginFactory.getPluginName(scriptFile);
return serverPluginFactory.get(scriptFile, snapshot, new PluginDescription(
pluginUserFactory.create(name), getPluginCanonicalWebUrl(name),
getPluginDataDir(name)));
}
static ClassLoader parentFor(Plugin.ApiType type)
throws InvalidPluginException {
switch (type) {
case EXTENSION:
return PluginName.class.getClassLoader();
case PLUGIN:
return PluginLoader.class.getClassLoader();
case JS:
return JavaScriptPlugin.class.getClassLoader();
default:
throw new InvalidPluginException("Unsupported ApiType " + type);
}
}
// 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 static Map<String, File> filterDisabled(
Multimap<String, File> pluginFiles) {
Map<String, File> activePlugins = Maps.newHashMapWithExpectedSize(
pluginFiles.keys().size());
for (String name : pluginFiles.keys()) {
for (File pluginFile : pluginFiles.asMap().get(name)) {
if (!pluginFile.getName().endsWith(".disabled")) {
assert(!activePlugins.containsKey(name));
activePlugins.put(name, pluginFile);
}
}
}
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 Multimap<String, File> prunePlugins(File pluginsDir) {
List<File> pluginFiles = scanFilesInPluginsDirectory(pluginsDir);
Multimap<String, File> map;
map = asMultimap(pluginFiles);
for (String plugin : map.keySet()) {
Collection<File> files = map.asMap().get(plugin);
if (files.size() == 1) {
continue;
}
// retrieve enabled plugins
Iterable<File> enabled = filterDisabledPlugins(
files);
// If we have only one (the winner) plugin, nothing to do
if (!Iterables.skip(enabled, 1).iterator().hasNext()) {
continue;
}
File 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<File> elementsToRemove = Lists.newArrayList();
Collection<File> elementsToAdd = Lists.newArrayList();
for (File loser : Iterables.skip(enabled, 1)) {
log.warn(String.format("Plugin <%s> was disabled, because"
+ " another plugin <%s>"
+ " with the same name <%s> already exists",
loser, winner, plugin));
File disabledPlugin = new File(loser + ".disabled");
elementsToAdd.add(disabledPlugin);
elementsToRemove.add(loser);
loser.renameTo(disabledPlugin);
}
Iterables.removeAll(files, elementsToRemove);
Iterables.addAll(files, elementsToAdd);
}
return map;
}
private List<File> scanFilesInPluginsDirectory(File pluginsDir) {
if (pluginsDir == null || !pluginsDir.exists()) {
return Collections.emptyList();
}
File[] matches = pluginsDir.listFiles(new FileFilter() {
@Override
public boolean accept(File pathname) {
String n = pathname.getName();
return !n.startsWith(".last_")
&& !n.startsWith(".next_");
}
});
if (matches == null) {
log.error("Cannot list " + pluginsDir.getAbsolutePath());
return Collections.emptyList();
}
return Arrays.asList(matches);
}
private static Iterable<File> filterDisabledPlugins(
Collection<File> files) {
return Iterables.filter(files, new Predicate<File>() {
@Override
public boolean apply(File file) {
return !file.getName().endsWith(".disabled");
}
});
}
public String getGerritPluginName(File srcFile) {
String fileName = srcFile.getName();
if (isJsPlugin(fileName)) {
return fileName.substring(0, fileName.length() - 3);
}
if (serverPluginFactory.handles(srcFile)) {
return serverPluginFactory.getPluginName(srcFile);
}
return null;
}
private Multimap<String, File> asMultimap(List<File> plugins) {
Multimap<String, File> map = LinkedHashMultimap.create();
for (File srcFile : plugins) {
map.put(getPluginName(srcFile), srcFile);
}
return map;
}
private static boolean isJsPlugin(String name) {
return isPlugin(name, "js");
}
private static 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");
}
}
}