Support removing multiple plugins at once in the CLI (#69063)

Closes #66476. Add support for removing multiple plugins at the
same time to `elasticsearch-plugin`. Also change references from
"plugin name" to "plugin id", to align better with the installer
class.
This commit is contained in:
Rory Hunter 2021-02-24 14:10:06 +00:00 committed by Rory Hunter
parent 8db1c9ce72
commit 119ba5ac65
3 changed files with 110 additions and 38 deletions

View file

@ -23,16 +23,19 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.StringJoiner;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import static org.elasticsearch.cli.Terminal.Verbosity.VERBOSE; import static org.elasticsearch.cli.Terminal.Verbosity.VERBOSE;
/** /**
* A command for the plugin CLI to remove a plugin from Elasticsearch. * A command for the plugin CLI to remove plugins from Elasticsearch.
*/ */
class RemovePluginCommand extends EnvironmentAwareCommand { class RemovePluginCommand extends EnvironmentAwareCommand {
@ -44,16 +47,16 @@ class RemovePluginCommand extends EnvironmentAwareCommand {
private final OptionSpec<String> arguments; private final OptionSpec<String> arguments;
RemovePluginCommand() { RemovePluginCommand() {
super("removes a plugin from Elasticsearch"); super("removes plugins from Elasticsearch");
this.purgeOption = parser.acceptsAll(Arrays.asList("p", "purge"), "Purge plugin configuration files"); this.purgeOption = parser.acceptsAll(Arrays.asList("p", "purge"), "Purge plugin configuration files");
this.arguments = parser.nonOptions("plugin name"); this.arguments = parser.nonOptions("plugin id");
} }
@Override @Override
protected void execute(final Terminal terminal, final OptionSet options, final Environment env) throws Exception { protected void execute(final Terminal terminal, final OptionSet options, final Environment env) throws Exception {
final String pluginName = arguments.value(options); final List<String> pluginIds = arguments.values(options);
final boolean purge = options.has(purgeOption); final boolean purge = options.has(purgeOption);
execute(terminal, env, pluginName, purge); execute(terminal, env, pluginIds, purge);
} }
/** /**
@ -61,55 +64,91 @@ class RemovePluginCommand extends EnvironmentAwareCommand {
* *
* @param terminal the terminal to use for input/output * @param terminal the terminal to use for input/output
* @param env the environment for the local node * @param env the environment for the local node
* @param pluginName the name of the plugin to remove * @param pluginIds the IDs of the plugins to remove
* @param purge if true, plugin configuration files will be removed but otherwise preserved * @param purge if true, plugin configuration files will be removed but otherwise preserved
* @throws IOException if any I/O exception occurs while performing a file operation * @throws IOException if any I/O exception occurs while performing a file operation
* @throws UserException if plugin name is null * @throws UserException if pluginIds is null or empty
* @throws UserException if plugin directory does not exist * @throws UserException if plugin directory does not exist
* @throws UserException if the plugin bin directory is not a directory * @throws UserException if the plugin bin directory is not a directory
*/ */
void execute(Terminal terminal, Environment env, String pluginName, boolean purge) throws IOException, UserException { void execute(Terminal terminal, Environment env, List<String> pluginIds, boolean purge) throws IOException, UserException {
if (pluginName == null) { if (pluginIds == null || pluginIds.isEmpty()) {
throw new UserException(ExitCodes.USAGE, "plugin name is required"); throw new UserException(ExitCodes.USAGE, "At least one plugin ID is required");
} }
// first make sure nothing extends this plugin ensurePluginsNotUsedByOtherPlugins(env, pluginIds);
List<String> usedBy = new ArrayList<>();
for (String pluginId : pluginIds) {
checkCanRemove(env, pluginId, purge);
}
for (String pluginId : pluginIds) {
removePlugin(env, terminal, pluginId, purge);
}
}
private void ensurePluginsNotUsedByOtherPlugins(Environment env, List<String> pluginIds) throws IOException, UserException {
// First make sure nothing extends this plugin
final Map<String, List<String>> usedBy = new HashMap<>();
Set<PluginsService.Bundle> bundles = PluginsService.getPluginBundles(env.pluginsFile()); Set<PluginsService.Bundle> bundles = PluginsService.getPluginBundles(env.pluginsFile());
for (PluginsService.Bundle bundle : bundles) { for (PluginsService.Bundle bundle : bundles) {
for (String extendedPlugin : bundle.plugin.getExtendedPlugins()) { for (String extendedPlugin : bundle.plugin.getExtendedPlugins()) {
if (extendedPlugin.equals(pluginName)) { for (String pluginId : pluginIds) {
usedBy.add(bundle.plugin.getName()); if (extendedPlugin.equals(pluginId)) {
usedBy.computeIfAbsent(bundle.plugin.getName(), (_key -> new ArrayList<>())).add(pluginId);
}
} }
} }
} }
if (usedBy.isEmpty() == false) { if (usedBy.isEmpty()) {
throw new UserException( return;
PLUGIN_STILL_USED,
"plugin [" + pluginName + "] cannot be removed" + " because it is extended by other plugins: " + usedBy
);
} }
final Path pluginDir = env.pluginsFile().resolve(pluginName); final StringJoiner message = new StringJoiner("\n");
final Path pluginConfigDir = env.configFile().resolve(pluginName); message.add("Cannot remove plugins because the following are extended by other plugins:");
final Path removing = env.pluginsFile().resolve(".removing-" + pluginName); usedBy.forEach((key, value) -> {
String s = "\t" + key + " used by " + value;
message.add(s);
});
throw new UserException(PLUGIN_STILL_USED, message.toString());
}
private void checkCanRemove(Environment env, String pluginId, boolean purge) throws UserException {
final Path pluginDir = env.pluginsFile().resolve(pluginId);
final Path pluginConfigDir = env.configFile().resolve(pluginId);
final Path removing = env.pluginsFile().resolve(".removing-" + pluginId);
terminal.println("-> removing [" + pluginName + "]...");
/* /*
* If the plugin does not exist and the plugin config does not exist, fail to the user that the plugin is not found, unless there's * If the plugin does not exist and the plugin config does not exist, fail to the user that the plugin is not found, unless there's
* a marker file left from a previously failed attempt in which case we proceed to clean up the marker file. Or, if the plugin does * a marker file left from a previously failed attempt in which case we proceed to clean up the marker file. Or, if the plugin does
* not exist, the plugin config does, and we are not purging, again fail to the user that the plugin is not found. * not exist, the plugin config does, and we are not purging, again fail to the user that the plugin is not found.
*/ */
if ((!Files.exists(pluginDir) && !Files.exists(pluginConfigDir) && !Files.exists(removing)) if ((Files.exists(pluginDir) == false && Files.exists(pluginConfigDir) == false && Files.exists(removing) == false)
|| (!Files.exists(pluginDir) && Files.exists(pluginConfigDir) && !purge)) { || (Files.exists(pluginDir) == false && Files.exists(pluginConfigDir) && purge == false)) {
final String message = String.format( final String message = String.format(
Locale.ROOT, Locale.ROOT,
"plugin [%s] not found; run 'elasticsearch-plugin list' to get list of installed plugins", "plugin [%s] not found; run 'elasticsearch-plugin list' to get list of installed plugins",
pluginName pluginId
); );
throw new UserException(ExitCodes.CONFIG, message); throw new UserException(ExitCodes.CONFIG, message);
} }
final Path pluginBinDir = env.binFile().resolve(pluginId);
if (Files.exists(pluginBinDir)) {
if (Files.isDirectory(pluginBinDir) == false) {
throw new UserException(ExitCodes.IO_ERROR, "bin dir for " + pluginId + " is not a directory");
}
}
}
private void removePlugin(Environment env, Terminal terminal, String pluginId, boolean purge) throws IOException {
final Path pluginDir = env.pluginsFile().resolve(pluginId);
final Path pluginConfigDir = env.configFile().resolve(pluginId);
final Path removing = env.pluginsFile().resolve(".removing-" + pluginId);
terminal.println("-> removing [" + pluginId + "]...");
final List<Path> pluginPaths = new ArrayList<>(); final List<Path> pluginPaths = new ArrayList<>();
/* /*
@ -123,11 +162,8 @@ class RemovePluginCommand extends EnvironmentAwareCommand {
terminal.println(VERBOSE, "removing [" + pluginDir + "]"); terminal.println(VERBOSE, "removing [" + pluginDir + "]");
} }
final Path pluginBinDir = env.binFile().resolve(pluginName); final Path pluginBinDir = env.binFile().resolve(pluginId);
if (Files.exists(pluginBinDir)) { if (Files.exists(pluginBinDir)) {
if (Files.isDirectory(pluginBinDir) == false) {
throw new UserException(ExitCodes.IO_ERROR, "bin dir for " + pluginName + " is not a directory");
}
try (Stream<Path> paths = Files.list(pluginBinDir)) { try (Stream<Path> paths = Files.list(pluginBinDir)) {
pluginPaths.addAll(paths.collect(Collectors.toList())); pluginPaths.addAll(paths.collect(Collectors.toList()));
} }
@ -180,7 +216,6 @@ class RemovePluginCommand extends EnvironmentAwareCommand {
// finally, add the marker file // finally, add the marker file
pluginPaths.add(removing); pluginPaths.add(removing);
IOUtils.rm(pluginPaths.toArray(new Path[pluginPaths.size()])); IOUtils.rm(pluginPaths.toArray(new Path[0]));
} }
} }

View file

@ -26,8 +26,11 @@ import java.io.StringReader;
import java.nio.file.DirectoryStream; import java.nio.file.DirectoryStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List;
import java.util.Map; import java.util.Map;
import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalTo;
@ -96,10 +99,14 @@ public class RemovePluginCommandTests extends ESTestCase {
); );
} }
static MockTerminal removePlugin(String name, Path home, boolean purge) throws Exception { static MockTerminal removePlugin(String pluginId, Path home, boolean purge) throws Exception {
return removePlugin(singletonList(pluginId), home, purge);
}
static MockTerminal removePlugin(List<String> pluginIds, Path home, boolean purge) throws Exception {
Environment env = TestEnvironment.newEnvironment(Settings.builder().put("path.home", home).build()); Environment env = TestEnvironment.newEnvironment(Settings.builder().put("path.home", home).build());
MockTerminal terminal = new MockTerminal(); MockTerminal terminal = new MockTerminal();
new MockRemovePluginCommand(env).execute(terminal, env, name, purge); new MockRemovePluginCommand(env).execute(terminal, env, pluginIds, purge);
return terminal; return terminal;
} }
@ -130,6 +137,23 @@ public class RemovePluginCommandTests extends ESTestCase {
assertRemoveCleaned(env); assertRemoveCleaned(env);
} }
/** Check that multiple plugins can be removed at the same time. */
public void testRemoveMultiple() throws Exception {
createPlugin("fake");
Files.createFile(env.pluginsFile().resolve("fake").resolve("plugin.jar"));
Files.createDirectory(env.pluginsFile().resolve("fake").resolve("subdir"));
createPlugin("other");
Files.createFile(env.pluginsFile().resolve("other").resolve("plugin.jar"));
Files.createDirectory(env.pluginsFile().resolve("other").resolve("subdir"));
removePlugin("fake", home, randomBoolean());
removePlugin("other", home, randomBoolean());
assertFalse(Files.exists(env.pluginsFile().resolve("fake")));
assertFalse(Files.exists(env.pluginsFile().resolve("other")));
assertRemoveCleaned(env);
}
public void testRemoveOldVersion() throws Exception { public void testRemoveOldVersion() throws Exception {
createPlugin( createPlugin(
"fake", "fake",
@ -237,7 +261,6 @@ public class RemovePluginCommandTests extends ESTestCase {
BufferedReader reader = new BufferedReader(new StringReader(terminal.getOutput())); BufferedReader reader = new BufferedReader(new StringReader(terminal.getOutput()));
BufferedReader errorReader = new BufferedReader(new StringReader(terminal.getErrorOutput())) BufferedReader errorReader = new BufferedReader(new StringReader(terminal.getErrorOutput()))
) { ) {
assertEquals("-> removing [fake]...", reader.readLine());
assertEquals( assertEquals(
"ERROR: plugin [fake] not found; run 'elasticsearch-plugin list' to get list of installed plugins", "ERROR: plugin [fake] not found; run 'elasticsearch-plugin list' to get list of installed plugins",
errorReader.readLine() errorReader.readLine()
@ -247,10 +270,14 @@ public class RemovePluginCommandTests extends ESTestCase {
} }
} }
public void testMissingPluginName() throws Exception { public void testMissingPluginName() {
UserException e = expectThrows(UserException.class, () -> removePlugin(null, home, randomBoolean())); UserException e = expectThrows(UserException.class, () -> removePlugin((List<String>) null, home, randomBoolean()));
assertEquals(ExitCodes.USAGE, e.exitCode); assertEquals(ExitCodes.USAGE, e.exitCode);
assertEquals("plugin name is required", e.getMessage()); assertEquals("At least one plugin ID is required", e.getMessage());
e = expectThrows(UserException.class, () -> removePlugin(emptyList(), home, randomBoolean()));
assertEquals(ExitCodes.USAGE, e.exitCode);
assertEquals("At least one plugin ID is required", e.getMessage());
} }
public void testRemoveWhenRemovingMarker() throws Exception { public void testRemoveWhenRemovingMarker() throws Exception {

View file

@ -182,6 +182,16 @@ purge the configuration files while removing a plugin, use `-p` or `--purge`.
This can option can be used after a plugin is removed to remove any lingering This can option can be used after a plugin is removed to remove any lingering
configuration files. configuration files.
[[removing-multiple-plugins]]
=== Removing multiple plugins
Multiple plugins can be removed in one invocation as follows:
[source,shell]
-----------------------------------
sudo bin/elasticsearch-plugin remove [pluginname] [pluginname] ... [pluginname]
-----------------------------------
[discrete] [discrete]
=== Updating plugins === Updating plugins