Allow plugins to register options for both ssh and restapi commands

Change-Id: I6976dffa98cb822a207a4de4c549486591e849e8
This commit is contained in:
Zac Livingston
2016-05-20 12:38:43 -06:00
parent 054d2b8ead
commit 4f083a841d
8 changed files with 247 additions and 4 deletions

View File

@@ -704,6 +704,37 @@ new `has:sample_pluginName` operand is shown below:
}
====
[[command_options]]
=== Command Options ===
Plugins can provide additional options for each of the gerrit ssh and the
REST API commands by implementing the DynamicBean interface and registering
it to a command class name in the plugin module's `configure()` method. The
plugin's name will be prepended to the name of each @Option annotation found
on the DynamicBean object provided by the plugin. The example below shows a
plugin that adds an option to log a value from the gerrit 'ban-commits'
ssh command.
[source, java]
----
public class SshModule extends AbstractModule {
private static final Logger log = LoggerFactory.getLogger(SshModule.class);
@Override
protected void configure() {
bind(DynamicOptions.DynamicBean.class)
.annotatedWith(Exports.named(
com.google.gerrit.sshd.commands.BanCommitCommand.class))
.to(BanOptions.class);
}
public static class BanOptions implements DynamicOptions.DynamicBean {
@Option(name = "--log", aliases = { "-l" }, usage = "Say Hello in the Log")
private void parse(String arg) {
log.error("Say Hello in the Log " + arg);
}
}
----
[[simple-configuration]]
== Simple Configuration in `gerrit.config`

View File

@@ -14,9 +14,11 @@
package com.google.gerrit.httpd.plugins;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.httpd.resources.Resource;
import com.google.gerrit.httpd.resources.ResourceKey;
import com.google.gerrit.httpd.resources.ResourceWeigher;
import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.server.cache.CacheModule;
import com.google.gerrit.server.plugins.ModuleGenerator;
import com.google.gerrit.server.plugins.ReloadPluginListener;
@@ -62,5 +64,7 @@ public class HttpPluginModule extends ServletModule {
.weigher(ResourceWeigher.class);
}
});
DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
}
}

View File

@@ -24,9 +24,11 @@ import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.ListMultimap;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.BinaryResult;
import com.google.gerrit.extensions.restapi.Url;
import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.util.cli.CmdLineParser;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
@@ -49,16 +51,19 @@ class ParameterParser {
ImmutableSet.of("pp", "prettyPrint", "strict", "callback", "alt", "fields");
private final CmdLineParser.Factory parserFactory;
private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
@Inject
ParameterParser(CmdLineParser.Factory pf) {
ParameterParser(CmdLineParser.Factory pf, DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
this.parserFactory = pf;
this.dynamicBeans = dynamicBeans;
}
<T> boolean parse(
T param, ListMultimap<String, String> in, HttpServletRequest req, HttpServletResponse res)
throws IOException {
CmdLineParser clp = parserFactory.create(param);
DynamicOptions.parse(dynamicBeans, clp, param);
try {
clp.parseOptionMap(in);
} catch (CmdLineException | NumberFormatException e) {

View File

@@ -0,0 +1,98 @@
// Copyright (C) 2016 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;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.util.cli.CmdLineParser;
import com.google.inject.Provider;
public class DynamicOptions {
/**
* To provide additional options, bind a DynamicBean. For example:
*
* <pre>
* bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
* .annotatedWith(Exports.named(com.google.gerrit.sshd.commands.Query.class))
* .to(MyOptions.class);
* </pre>
*
* To define the additional options, implement this interface. For example:
*
* <pre>
* public class MyOptions implements DynamicOptions.DynamicBean {
* {@literal @}Option(name = "--verbose", aliases = {"-v"}
* usage = "Make the operation more talkative")
* public boolean verbose;
* }
* </pre>
*
* The option will be prefixed by the plugin name. In the example above, if the plugin name was
* my-plugin, then the --verbose option as used by the caller would be --my-plugin--verbose.
*/
public interface DynamicBean {}
/**
* The entity which provided additional options may need a way to receive a reference to the
* DynamicBean it provided. To do so, the existing class should implement BeanReceiver (a setter)
* and then provide some way for the plugin to request its DynamicBean (a getter.) For example:
*
* <pre>
* public class Query extends SshCommand implements DynamicOptions.BeanReceiver {
* public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {
* dynamicBeans.put(plugin, dynamicBean);
* }
*
* public DynamicOptions.DynamicBean getDynamicBean(String plugin) {
* return dynamicBeans.get(plugin);
* }
* ...
* }
* }
* </pre>
*/
public interface BeanReceiver {
void setDynamicBean(String plugin, DynamicBean dynamicBean);
}
/**
* To include options from DynamicBeans, setup a DynamicMap and call this parse method. For
* example:
*
* <pre>
* DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
*
* ...
*
* protected void parseCommandLine(Object options) throws UnloggedFailure {
* final CmdLineParser clp = newCmdLineParser(options);
* DynamicOptions.parse(dynamicBeans, clp, options);
* ...
* }
* </pre>
*/
public static void parse(DynamicMap<DynamicBean> dynamicBeans, CmdLineParser clp, Object bean) {
for (String plugin : dynamicBeans.plugins()) {
Provider<DynamicBean> provider =
dynamicBeans.byPlugin(plugin).get(bean.getClass().getCanonicalName());
if (provider != null) {
DynamicBean dynamicBean = provider.get();
clp.parseWithPrefix(plugin, dynamicBean);
if (bean instanceof BeanReceiver) {
((BeanReceiver) bean).setDynamicBean(plugin, dynamicBean);
}
}
}
}
}

View File

@@ -20,8 +20,10 @@ import com.google.common.util.concurrent.Atomics;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.TimeUtil;
import com.google.gerrit.extensions.annotations.PluginName;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.RequestCleanup;
import com.google.gerrit.server.git.ProjectRunnable;
@@ -84,6 +86,8 @@ public abstract class BaseCommand implements Command {
@Inject private SshScope.Context context;
@Inject private DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
/** Commands declared by a plugin can be scoped by the plugin name. */
@Inject(optional = true)
@PluginName
@@ -193,6 +197,7 @@ public abstract class BaseCommand implements Command {
*/
protected void parseCommandLine(Object options) throws UnloggedFailure {
final CmdLineParser clp = newCmdLineParser(options);
DynamicOptions.parse(dynamicBeans, clp, options);
try {
clp.parseArgument(argv);
} catch (IllegalArgumentException | CmdLineException err) {

View File

@@ -17,7 +17,9 @@ package com.google.gerrit.sshd;
import static com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes.registerInParentInjectors;
import static com.google.inject.Scopes.SINGLETON;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.lifecycle.LifecycleModule;
import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.server.PeerDaemonUser;
import com.google.gerrit.server.RemotePeer;
import com.google.gerrit.server.config.GerritRequestModule;
@@ -94,6 +96,8 @@ public class SshModule extends LifecycleModule {
.annotatedWith(UniqueAnnotations.create())
.to(SshPluginStarterCallback.class);
DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
listener().toInstance(registerInParentInjectors());
listener().to(SshLog.class);
listener().to(SshDaemon.class);

View File

@@ -14,6 +14,8 @@
package com.google.gerrit.sshd;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.server.plugins.Plugin;
import com.google.gerrit.server.plugins.ReloadPluginListener;
import com.google.gerrit.server.plugins.StartPluginListener;
@@ -30,10 +32,14 @@ class SshPluginStarterCallback implements StartPluginListener, ReloadPluginListe
private static final Logger log = LoggerFactory.getLogger(SshPluginStarterCallback.class);
private final DispatchCommandProvider root;
private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
@Inject
SshPluginStarterCallback(@CommandName(Commands.ROOT) DispatchCommandProvider root) {
SshPluginStarterCallback(
@CommandName(Commands.ROOT) DispatchCommandProvider root,
DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
this.root = root;
this.dynamicBeans = dynamicBeans;
}
@Override
@@ -58,10 +64,19 @@ class SshPluginStarterCallback implements StartPluginListener, ReloadPluginListe
try {
return plugin.getSshInjector().getProvider(key);
} catch (RuntimeException err) {
if (!providesDynamicOptions(plugin)) {
log.warn(
String.format("Plugin %s did not define its top-level command", plugin.getName()), err);
String.format(
"Plugin %s did not define its top-level command nor any DynamicOptions",
plugin.getName()),
err);
}
}
}
return null;
}
private boolean providesDynamicOptions(Plugin plugin) {
return dynamicBeans.plugins().contains(plugin.getName());
}
}

View File

@@ -44,6 +44,8 @@ import java.io.StringWriter;
import java.io.Writer;
import java.lang.annotation.Annotation;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -58,8 +60,10 @@ import org.kohsuke.args4j.OptionDef;
import org.kohsuke.args4j.spi.BooleanOptionHandler;
import org.kohsuke.args4j.spi.EnumOptionHandler;
import org.kohsuke.args4j.spi.FieldSetter;
import org.kohsuke.args4j.spi.MethodSetter;
import org.kohsuke.args4j.spi.OptionHandler;
import org.kohsuke.args4j.spi.Setter;
import org.kohsuke.args4j.spi.Setters;
/**
* Extended command line parser which handles --foo=value arguments.
@@ -253,6 +257,10 @@ public class CmdLineParser {
return findHandler(makeOption(name)) instanceof BooleanOptionHandler;
}
public void parseWithPrefix(String prefix, Object bean) {
parser.parseWithPrefix(prefix, bean);
}
private String makeOption(String name) {
if (!name.startsWith("-")) {
if (name.length() == 1) {
@@ -313,6 +321,60 @@ public class CmdLineParser {
throw new CmdLineException(parser, String.format("invalid boolean \"%s=%s\"", name, value));
}
private static class PrefixedOption implements Option {
String prefix;
Option o;
PrefixedOption(String prefix, Option o) {
this.prefix = prefix;
this.o = o;
}
public String name() {
return getPrefixedName(prefix, o.name());
}
public String[] aliases() {
String[] prefixedAliases = new String[o.aliases().length];
for (int i = 0; i < prefixedAliases.length; i++) {
prefixedAliases[i] = getPrefixedName(prefix, o.aliases()[i]);
}
return prefixedAliases;
}
public String usage() {
return o.usage();
}
public String metaVar() {
return o.metaVar();
}
public boolean required() {
return o.required();
}
public boolean hidden() {
return o.hidden();
}
public Class<? extends OptionHandler> handler() {
return o.handler();
}
public String[] depends() {
return o.depends();
}
public Class<? extends Annotation> annotationType() {
return o.annotationType();
}
private static String getPrefixedName(String prefix, String name) {
return "--" + prefix + name;
}
}
private class MyParser extends org.kohsuke.args4j.CmdLineParser {
@SuppressWarnings("rawtypes")
private List<OptionHandler> optionsList;
@@ -324,6 +386,25 @@ public class CmdLineParser {
ensureOptionsInitialized();
}
// NOTE: Argument annotations on bean are ignored.
public void parseWithPrefix(String prefix, Object bean) {
// recursively process all the methods/fields.
for (Class c = bean.getClass(); c != null; c = c.getSuperclass()) {
for (Method m : c.getDeclaredMethods()) {
Option o = m.getAnnotation(Option.class);
if (o != null) {
addOption(new MethodSetter(this, bean, m), new PrefixedOption(prefix, o));
}
}
for (Field f : c.getDeclaredFields()) {
Option o = f.getAnnotation(Option.class);
if (o != null) {
addOption(Setters.create(f, bean), new PrefixedOption(prefix, o));
}
}
}
}
@SuppressWarnings({"unchecked", "rawtypes"})
@Override
protected OptionHandler createOptionHandler(final OptionDef option, final Setter setter) {