Allow plugins to provide their own name

Plugin name is an important part for plugin developers and users: it drives
SSH command names, REST API endpoints, used as a prefix in project.config for
plugin-owned capabilities and can be used in project.config sections for
project specific configuration.

Currently there is no way for a plugin to provide its own name. When a new
version of a plugin is released and it is copied to $site/plugins folders
with unchanged name:

 replication-2.8.SNAPSHOT.jar

then the plugin name in all places mentioned above is changed.

With this change plugin name can be optionally provided by plugins, instead of
deriving it from the plugin file name. To provide its own plugin name, plugin
has to put the following line in the manifest file:

 Gerrit-PluginName: replication

This is especially useful for plugins that contribute plugin-owned capabilities
that are stored in project.config file as

 [plugin-name]-[capability-name]

Other use case is to be able to put project specific plugin configuration
section in project.config. In this case it is advantageous to reserve the
plugin name to access the configuration section in project.config file.

Multiple versions of the same plugin with different file names can not be
deployed on one Gerrit installation site: only the first plugin can be
successful deployed. All other plugins with the same name are disabled.

If plugin provides its own name, it has some implications for SSH install
command and PUT /plugins/plugin-name REST endpoint. In both cases a name of the
plugin to install is passed from the user. If the plugin provides its own name
in MANIFEST file, the plugin name from the MANIFEST file has precedence over
the name passed to the SSH command or REST endpoint.

Change-Id: If28349e95be2e90c6ee8169a72ba8cd642b49b98
This commit is contained in:
David Ostrovsky 2013-09-05 19:59:09 +02:00 committed by David Pursehouse
parent b00e99f5aa
commit 366ad0eae8
5 changed files with 202 additions and 34 deletions

View File

@ -41,7 +41,9 @@ OPTIONS
--name::
-n::
The name under which the plugin should be installed.
The name under which the plugin should be installed. Note: if the plugin
provides its own name in the MANIFEST file, then the plugin name from the
MANIFEST file has precedence over this option.
EXAMPLES
--------

View File

@ -132,6 +132,48 @@ will be performed by scanning all classes in the plugin JAR for
Gerrit-HttpModule: tld.example.project.HttpModuleClassName
====
[[plugin_name]]
Plugin Name
~~~~~~~~~~~
Plugin can optionally provide its own plugin name.
====
Gerrit-PluginName: replication
====
This is useful for plugins that contribute plugin-owned capabilities that
are stored in the `project.config` file. Another use case is to be able to put
project specific plugin configuration section in `project.config`. In this
case it is advantageous to reserve the plugin name to access the configuration
section in the `project.config` file.
If `Gerrit-PluginName` is omitted, then the plugin's name is determined from
the plugin file name.
If a plugin provides its own name, then that plugin cannot be deployed
multiple times under different file names on one Gerrit site.
For Maven driven plugins, the following line must be included in the pom.xml
file:
[source,xml]
----
<manifestEntries>
<Gerrit-PluginName>name</Gerrit-PluginName>
</manifestEntries>
----
For Buck driven plugins, the following line must be included in the BUCK
configuration file:
[source,python]
----
manifest_entries = [
'Gerrit-PluginName: name',
]
----
[[reload_method]]
Reload Method
~~~~~~~~~~~~~

View File

@ -63,7 +63,9 @@ Install Plugin
'PUT /plugins/link:#plugin-id[\{plugin-id\}]'
Installs a new plugin on the Gerrit server. If a plugin with the
specified name already exists it is overwritten.
specified name already exists it is overwritten. Note: if the plugin
provides its own name in the MANIFEST file, then the plugin name from
the MANIFEST file has precedence over the \{plugin-id\} above.
The plugin jar can either be sent as binary data in the request body
or a URL to the plugin jar must be provided in the request body inside
@ -242,7 +244,6 @@ IDs
~~~~~~~~~~~~~
The ID of the plugin.
[[json-entities]]
JSON Entities
-------------

View File

@ -97,7 +97,7 @@ public class ListPlugins implements RestReadView<TopLevelResource> {
});
if (!format.isJson()) {
stdout.format("%-30s %-10s %-8s\n", "Name", "Version", "Status");
stdout.format("%-30s %-10s %-8s %s\n", "Name", "Version", "Status", "File");
stdout.print("-------------------------------------------------------------------------------\n");
}
@ -106,9 +106,10 @@ public class ListPlugins implements RestReadView<TopLevelResource> {
if (format.isJson()) {
output.put(p.getName(), info);
} else {
stdout.format("%-30s %-10s %-8s\n", p.getName(),
stdout.format("%-30s %-10s %-8s %s\n", p.getName(),
Strings.nullToEmpty(info.version),
p.isDisabled() ? "DISABLED" : "ENABLED");
p.isDisabled() ? "DISABLED" : "ENABLED",
p.getSrcFile().getName());
}
}

View File

@ -15,9 +15,14 @@
package com.google.gerrit.server.plugins;
import com.google.common.base.Joiner;
import com.google.common.base.Objects;
import com.google.common.base.Predicate;
import com.google.common.base.Strings;
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;
@ -49,6 +54,7 @@ import java.net.URLClassLoader;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.Iterator;
@ -129,17 +135,22 @@ public class PluginLoader implements LifecycleListener {
}
}
public void installPluginFromStream(String name, InputStream in)
public void installPluginFromStream(String originalName, InputStream in)
throws IOException, PluginInstallException {
String fileName = name;
String fileName = originalName;
if (!fileName.endsWith(".jar")) {
fileName += ".jar";
}
File dst = new File(pluginsDir, fileName);
name = nameOf(dst);
File tmp = asTemp(in, ".next_" + fileName + "_", ".tmp", pluginsDir);
String name = Objects.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));
}
File dst = new File(pluginsDir, name + ".jar");
synchronized (this) {
Plugin active = running.get(name);
if (active != null) {
@ -325,16 +336,16 @@ public class PluginLoader implements LifecycleListener {
}
public synchronized void rescan() {
List<File> jars = scanJarsInPluginsDirectory();
stopRemovedPlugins(jars);
dropRemovedDisabledPlugins(jars);
Multimap<String, File> jars = prunePlugins(pluginsDir);
if (jars.isEmpty()) {
return;
}
for (File jar : jars) {
if (jar.getName().endsWith(".disabled")) {
continue;
}
syncDisabledPlugins(jars);
String name = nameOf(jar);
Map<String, File> activePlugins = filterDisabled(jars);
for (String name : activePlugins.keySet()) {
File jar = activePlugins.get(name);
FileSnapshot brokenTime = broken.get(name);
if (brokenTime != null && !brokenTime.isModified(jar)) {
continue;
@ -346,13 +357,15 @@ public class PluginLoader implements LifecycleListener {
}
if (active != null) {
log.info(String.format("Reloading plugin %s", active.getName()));
log.info(String.format("Reloading plugin %s, version %s",
active.getName(), active.getVersion()));
}
try {
Plugin loadedPlugin = runPlugin(name, jar, active);
if (active == null && !loadedPlugin.isDisabled()) {
log.info(String.format("Loaded plugin %s", loadedPlugin.getName()));
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());
@ -362,6 +375,11 @@ public class PluginLoader implements LifecycleListener {
cleanInBackground();
}
private void syncDisabledPlugins(Multimap<String, File> jars) {
stopRemovedPlugins(jars);
dropRemovedDisabledPlugins(jars);
}
private Plugin runPlugin(String name, File jar, Plugin oldPlugin)
throws PluginInstallException {
FileSnapshot snapshot = FileSnapshot.save(jar);
@ -395,23 +413,27 @@ public class PluginLoader implements LifecycleListener {
}
}
private void stopRemovedPlugins(List<File> jars) {
private void stopRemovedPlugins(Multimap<String, File> jars) {
Set<String> unload = Sets.newHashSet(running.keySet());
for (File jar : jars) {
if (!jar.getName().endsWith(".disabled")) {
unload.remove(nameOf(jar));
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){
for (String name : unload) {
unloadPlugin(running.get(name));
}
}
private void dropRemovedDisabledPlugins(List<File> jars) {
private void dropRemovedDisabledPlugins(Multimap<String, File> jars) {
Set<String> unload = Sets.newHashSet(disabled.keySet());
for (File jar : jars) {
if (jar.getName().endsWith(".disabled")) {
unload.remove(nameOf(jar));
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) {
@ -439,7 +461,10 @@ public class PluginLoader implements LifecycleListener {
}
private static String nameOf(File jar) {
String name = jar.getName();
return nameOf(jar.getName());
}
private static String nameOf(String name) {
if (name.endsWith(".disabled")) {
name = name.substring(0, name.lastIndexOf('.'));
}
@ -513,7 +538,7 @@ public class PluginLoader implements LifecycleListener {
return PLUGIN_TMP_PREFIX + name + "_" + fmt.format(new Date()) + "_";
}
private Class<? extends Module> load(String name, ClassLoader pluginLoader)
private static Class<? extends Module> load(String name, ClassLoader pluginLoader)
throws ClassNotFoundException {
if (Strings.isNullOrEmpty(name)) {
return null;
@ -530,7 +555,74 @@ public class PluginLoader implements LifecycleListener {
return clazz;
}
private List<File> scanJarsInPluginsDirectory() {
// 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> jars) {
Map<String, File> activePlugins = Maps.newHashMapWithExpectedSize(
jars.keys().size());
for (String name : jars.keys()) {
for (File jar : jars.asMap().get(name)) {
if (!jar.getName().endsWith(".disabled")) {
assert(!activePlugins.containsKey(name));
activePlugins.put(name, jar);
}
}
}
return activePlugins;
}
// Scan the $site_path/plugins directory and fetch all files that end
// with *.jar. The Key in returned multimap is the plugin name. 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.
private static Multimap<String, File> prunePlugins(File pluginsDir) {
List<File> jars = scanJarsInPluginsDirectory(pluginsDir);
Multimap<String, File> map;
try {
map = asMultimap(jars);
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);
}
} catch (IOException e) {
log.warn("Cannot prune plugin list",
e.getCause());
return LinkedHashMultimap.create();
}
return map;
}
private static List<File> scanJarsInPluginsDirectory(File pluginsDir) {
if (pluginsDir == null || !pluginsDir.exists()) {
return Collections.emptyList();
}
@ -550,4 +642,34 @@ public class PluginLoader implements LifecycleListener {
}
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");
}
});
}
private static String getGerritPluginName(File srcFile) throws IOException {
JarFile jarFile = new JarFile(srcFile);
try {
return jarFile.getManifest().getMainAttributes()
.getValue("Gerrit-PluginName");
} finally {
jarFile.close();
}
}
private static Multimap<String, File> asMultimap(List<File> plugins)
throws IOException {
Multimap<String, File> map = LinkedHashMultimap.create();
for (File srcFile : plugins) {
map.put(Objects.firstNonNull(getGerritPluginName(srcFile),
nameOf(srcFile)), srcFile);
}
return map;
}
}