From 5a99e04ccffbeff4d50dbbb426dcc323c0be7362 Mon Sep 17 00:00:00 2001 From: "Shawn O. Pearce" Date: Tue, 8 May 2012 22:44:25 -0700 Subject: [PATCH] SSH commands gerrit plugin {ls|install|remove|reload} Administrators can use these commands to view loaded plugins, install new plugins from any URL source the server can reach (including HTTP), and disable plugins that might not required or are misbehaving. When installing plugins the JAR can be supplied as stdin to the SSH command, or as a URL. Either way the server copies the JAR into a temporary file in the $site_dir/plugins directory, then safely does a reload of the plugin by stopping the running plugin, replacing the JAR on disk, and then starting the plugin back up. This is safer than the rescan thread detecting changes made in the background. Removing a plugin doesn't actually delete it, rather the plugin will be renamed to have a ".disabled" suffix, preventing the scanner from considering that plugin during a future server restart. Change-Id: Iaa4cb9d7c28294fa616975093a0d4ffb1fcb2788 --- .../google/gerrit/server/plugins/Plugin.java | 17 +++ .../plugins/PluginInstallException.java | 23 ++++ .../gerrit/server/plugins/PluginLoader.java | 88 ++++++++++++++- .../sshd/commands/DefaultCommandModule.java | 9 ++ .../sshd/commands/PluginInstallCommand.java | 103 ++++++++++++++++++ .../gerrit/sshd/commands/PluginLsCommand.java | 51 +++++++++ .../sshd/commands/PluginReloadCommand.java | 32 ++++++ .../sshd/commands/PluginRemoveCommand.java | 42 +++++++ 8 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginInstallException.java create mode 100644 gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java create mode 100644 gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginLsCommand.java create mode 100644 gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java create mode 100644 gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java index ac6a8665ae..0c1ab0fdaf 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java @@ -24,11 +24,15 @@ import com.google.inject.Module; import org.eclipse.jgit.storage.file.FileSnapshot; import java.io.File; +import java.util.jar.Attributes; +import java.util.jar.Manifest; import javax.annotation.Nullable; public class Plugin { private final String name; + private final File jar; + private final Manifest manifest; private final FileSnapshot snapshot; private Class sysModule; private Class sshModule; @@ -38,19 +42,32 @@ public class Plugin { private LifecycleManager manager; public Plugin(String name, + File jar, + Manifest manifest, FileSnapshot snapshot, @Nullable Class sysModule, @Nullable Class sshModule) { this.name = name; + this.jar = jar; + this.manifest = manifest; this.snapshot = snapshot; this.sysModule = sysModule; this.sshModule = sshModule; } + File getJar() { + return jar; + } + public String getName() { return name; } + public String getVersion() { + Attributes main = manifest.getMainAttributes(); + return main.getValue(Attributes.Name.IMPLEMENTATION_VERSION); + } + boolean isModified(File jar) { return snapshot.lastModified() != jar.lastModified(); } diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginInstallException.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginInstallException.java new file mode 100644 index 0000000000..77fa7028e3 --- /dev/null +++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginInstallException.java @@ -0,0 +1,23 @@ +// 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; + +public class PluginInstallException extends Exception { + private static final long serialVersionUID = 1L; + + public PluginInstallException(Throwable why) { + super(why.getMessage(), why); + } +} diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java index 5bb1b36eca..44b2f12aee 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java @@ -15,6 +15,7 @@ package com.google.gerrit.server.plugins; import com.google.common.base.Strings; +import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.google.gerrit.lifecycle.LifecycleListener; @@ -32,7 +33,9 @@ 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.net.URL; import java.net.URLClassLoader; import java.util.Arrays; @@ -70,6 +73,89 @@ public class PluginLoader implements LifecycleListener { TimeUnit.MINUTES.toMillis(1), TimeUnit.MILLISECONDS)); } + public synchronized List getPlugins() { + return Lists.newArrayList(running.values()); + } + + public void installPluginFromStream(String name, InputStream in) + throws IOException, PluginInstallException { + if (!name.endsWith(".jar")) { + name += ".jar"; + } + + File jar = new File(pluginsDir, name); + name = nameOf(jar); + + File old = new File(pluginsDir, ".last_" + name + ".zip"); + File tmp = copyToTemp(name, in); + + synchronized (this) { + Plugin active = running.get(name); + if (active != null) { + log.info(String.format("Replacing plugin %s", name)); + active.stop(); + running.remove(name); + old.delete(); + jar.renameTo(old); + } + + tmp.renameTo(jar); + FileSnapshot snapshot = FileSnapshot.save(jar); + Plugin next; + try { + next = loadPlugin(name, snapshot, jar); + next.start(env); + } catch (Throwable err) { + jar.delete(); + throw new PluginInstallException(err); + } + broken.remove(name); + running.put(name, next); + if (active == null) { + log.info(String.format("Installed plugin %s", name)); + } + } + } + + private File copyToTemp(String name, InputStream in) throws IOException { + File tmp = File.createTempFile(".next_" + name, ".zip", pluginsDir); + boolean keep = false; + try { + FileOutputStream out = new FileOutputStream(tmp); + try { + byte[] data = new byte[8192]; + int n; + while ((n = in.read(data)) > 0) { + out.write(data, 0, n); + } + keep = true; + return tmp; + } finally { + out.close(); + } + } finally { + if (!keep) { + tmp.delete(); + } + } + } + + public synchronized void disablePlugins(Set names) { + for (String name : names) { + Plugin active = running.get(name); + if (active == null) { + continue; + } + + log.info(String.format("Disabling plugin %s", name)); + active.stop(); + running.remove(name); + + File off = new File(pluginsDir, active.getName() + ".jar.disabled"); + active.getJar().renameTo(off); + } + } + @Override public synchronized void start() { log.info("Loading plugins from " + pluginsDir.getAbsolutePath()); @@ -166,7 +252,7 @@ public class PluginLoader implements LifecycleListener { Class sysModule = load(sysName, pluginLoader); Class sshModule = load(sshName, pluginLoader); - return new Plugin(name, snapshot, sysModule, sshModule); + return new Plugin(name, jarFile, manifest, snapshot, sysModule, sshModule); } private Class load(String name, ClassLoader pluginLoader) diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java index 4d7c93e02a..64e7289aef 100644 --- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java +++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java @@ -28,6 +28,7 @@ public class DefaultCommandModule extends CommandModule { protected void configure() { final CommandName git = Commands.named("git"); final CommandName gerrit = Commands.named("gerrit"); + final CommandName plugin = Commands.named(gerrit, "plugin"); // The following commands can be ran on a server in either Master or Slave // mode. If a command should only be used on a server in one mode, but not @@ -46,6 +47,14 @@ public class DefaultCommandModule extends CommandModule { command(gerrit, "stream-events").to(StreamEvents.class); command(gerrit, "version").to(VersionCommand.class); + command(gerrit, "plugin").toProvider(new DispatchCommandProvider(plugin)); + command(plugin, "ls").to(PluginLsCommand.class); + command(plugin, "install").to(PluginInstallCommand.class); + command(plugin, "reload").to(PluginReloadCommand.class); + command(plugin, "remove").to(PluginRemoveCommand.class); + command(plugin, "add").to(Commands.key(plugin, "install")); + command(plugin, "rm").to(Commands.key(plugin, "remove")); + command(git).toProvider(new DispatchCommandProvider(git)); command(git, "receive-pack").to(Commands.key(gerrit, "receive-pack")); command(git, "upload-pack").to(Upload.class); diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java new file mode 100644 index 0000000000..2328847661 --- /dev/null +++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java @@ -0,0 +1,103 @@ +// 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.sshd.commands; + +import com.google.common.base.Strings; +import com.google.gerrit.common.data.GlobalCapability; +import com.google.gerrit.server.plugins.PluginInstallException; +import com.google.gerrit.server.plugins.PluginLoader; +import com.google.gerrit.sshd.RequiresCapability; +import com.google.gerrit.sshd.SshCommand; +import com.google.inject.Inject; + +import org.kohsuke.args4j.Argument; +import org.kohsuke.args4j.Option; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; + +@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER) +final class PluginInstallCommand extends SshCommand { + @Option(name = "--name", aliases = {"-n"}, usage = "install under name") + private String name; + + @Option(name = "-") + void useInput(boolean on) { + source = "-"; + } + + @Argument(index = 0, metaVar = "-|URL", usage = "JAR to load") + private String source; + + @Inject + private PluginLoader loader; + + @Override + protected void run() throws UnloggedFailure { + if (Strings.isNullOrEmpty(source)) { + throw die("Argument \"-|URL\" is required"); + } + if (Strings.isNullOrEmpty(name) && "-".equalsIgnoreCase(source)) { + throw die("--name required when source is stdin"); + } + + if (Strings.isNullOrEmpty(name)) { + int s = source.lastIndexOf('/'); + if (0 <= s) { + name = source.substring(s + 1); + } else { + name = source; + } + } + + InputStream data; + if ("-".equalsIgnoreCase(source)) { + data = in; + } else if (new File(source).isFile() + && source.equals(new File(source).getAbsolutePath())) { + try { + data = new FileInputStream(new File(source)); + } catch (FileNotFoundException e) { + throw die("cannot read " + source); + } + } else { + try { + data = new URL(source).openStream(); + } catch (MalformedURLException e) { + throw die("invalid url " + source); + } catch (IOException e) { + throw die("cannot read " + source); + } + } + try { + loader.installPluginFromStream(name, data); + } catch (IOException e) { + throw die("cannot install plugin"); + } catch (PluginInstallException e) { + e.printStackTrace(stderr); + throw die("plugin failed to install"); + } finally { + try { + data.close(); + } catch (IOException err) { + } + } + } +} diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginLsCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginLsCommand.java new file mode 100644 index 0000000000..6044151ddc --- /dev/null +++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginLsCommand.java @@ -0,0 +1,51 @@ +// 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.sshd.commands; + +import com.google.common.base.Strings; +import com.google.gerrit.common.data.GlobalCapability; +import com.google.gerrit.server.plugins.Plugin; +import com.google.gerrit.server.plugins.PluginLoader; +import com.google.gerrit.sshd.RequiresCapability; +import com.google.gerrit.sshd.SshCommand; +import com.google.inject.Inject; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER) +final class PluginLsCommand extends SshCommand { + @Inject + private PluginLoader loader; + + @Override + protected void run() { + List running = loader.getPlugins(); + Collections.sort(running, new Comparator() { + @Override + public int compare(Plugin a, Plugin b) { + return a.getName().compareTo(b.getName()); + } + }); + + stdout.format("%-30s %-10s\n", "Name", "Version"); + stdout.print("----------------------------------------------------------------------\n"); + for (Plugin p : running) { + stdout.format("%-30s %-10s\n", p.getName(), + Strings.nullToEmpty(p.getVersion())); + } + } +} diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java new file mode 100644 index 0000000000..548669866b --- /dev/null +++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java @@ -0,0 +1,32 @@ +// 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.sshd.commands; + +import com.google.gerrit.common.data.GlobalCapability; +import com.google.gerrit.server.plugins.PluginLoader; +import com.google.gerrit.sshd.RequiresCapability; +import com.google.gerrit.sshd.SshCommand; +import com.google.inject.Inject; + +@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER) +final class PluginReloadCommand extends SshCommand { + @Inject + private PluginLoader loader; + + @Override + protected void run() { + loader.rescan(); + } +} diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java new file mode 100644 index 0000000000..6444e71ee7 --- /dev/null +++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java @@ -0,0 +1,42 @@ +// 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.sshd.commands; + +import com.google.common.collect.Sets; +import com.google.gerrit.common.data.GlobalCapability; +import com.google.gerrit.server.plugins.PluginLoader; +import com.google.gerrit.sshd.RequiresCapability; +import com.google.gerrit.sshd.SshCommand; +import com.google.inject.Inject; + +import org.kohsuke.args4j.Argument; + +import java.util.List; + +@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER) +final class PluginRemoveCommand extends SshCommand { + @Argument(index = 0, metaVar = "NAME", required = true, usage = "plugin to remove") + List names; + + @Inject + private PluginLoader loader; + + @Override + protected void run() { + if (names != null && !names.isEmpty()) { + loader.disablePlugins(Sets.newHashSet(names)); + } + } +}