summaryrefslogtreecommitdiff
path: root/distribution
diff options
context:
space:
mode:
authorRyan Ernst <ryan@iernst.net>2017-04-21 09:25:58 -0700
committerGitHub <noreply@github.com>2017-04-21 09:25:58 -0700
commitba4867469500b464c67da17226de745f34253163 (patch)
tree5b759129592a21f247727187f319b50e33c34fdd /distribution
parentbadb2be06682a14afbd898145368b0b30e151182 (diff)
Build: Move plugin cli and tests to distribution tool (#24220)
The plugin cli currently resides inside the elasticsearch jar. This commit moves it into a plugin-cli jar. This is change alone is a no-op; it does not change anything about what is loaded at runtime. But it will allow easier testing (with fixtures in the future to test ES or maven installation), as well as eventually not loading these classes when starting elasticsearch.
Diffstat (limited to 'distribution')
-rw-r--r--distribution/build.gradle1
-rw-r--r--distribution/tools/plugin-cli/build.gradle32
-rw-r--r--distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/InstallPluginCommand.java629
-rw-r--r--distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/ListPluginsCommand.java72
-rw-r--r--distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/PluginCli.java55
-rw-r--r--distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/ProgressInputStream.java83
-rw-r--r--distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/RemovePluginCommand.java134
-rw-r--r--distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/InstallPluginCommandTests.java693
-rw-r--r--distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/ListPluginsCommandTests.java240
-rw-r--r--distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/ProgressInputStreamTests.java116
-rw-r--r--distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/RemovePluginCommandTests.java167
11 files changed, 2222 insertions, 0 deletions
diff --git a/distribution/build.gradle b/distribution/build.gradle
index b02c4ed880..75b40d57b1 100644
--- a/distribution/build.gradle
+++ b/distribution/build.gradle
@@ -165,6 +165,7 @@ configure(distributions) {
from project(':core').configurations.runtime
// delay add tools using closures, since they have not yet been configured, so no jar task exists yet
from { project(':distribution:tools:java-version-checker').jar }
+ from { project(':distribution:tools:plugin-cli').jar }
}
modulesFiles = copySpec {
diff --git a/distribution/tools/plugin-cli/build.gradle b/distribution/tools/plugin-cli/build.gradle
new file mode 100644
index 0000000000..ae3dca9ef8
--- /dev/null
+++ b/distribution/tools/plugin-cli/build.gradle
@@ -0,0 +1,32 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you 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.
+ */
+
+apply plugin: 'elasticsearch.build'
+
+dependencies {
+ provided "org.elasticsearch:elasticsearch:${version}"
+ testCompile "org.elasticsearch.test:framework:${version}"
+ testCompile 'com.google.jimfs:jimfs:1.1'
+ testCompile 'com.google.guava:guava:18.0'
+}
+
+test {
+ // TODO: find a way to add permissions for the tests in this module
+ systemProperty 'tests.security.manager', 'false'
+}
diff --git a/distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/InstallPluginCommand.java b/distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/InstallPluginCommand.java
new file mode 100644
index 0000000000..afe4593e62
--- /dev/null
+++ b/distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/InstallPluginCommand.java
@@ -0,0 +1,629 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you 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 org.elasticsearch.plugins;
+
+import joptsimple.OptionSet;
+import joptsimple.OptionSpec;
+import org.apache.lucene.search.spell.LevensteinDistance;
+import org.apache.lucene.util.CollectionUtil;
+import org.apache.lucene.util.IOUtils;
+import org.elasticsearch.Version;
+import org.elasticsearch.bootstrap.JarHell;
+import org.elasticsearch.cli.EnvironmentAwareCommand;
+import org.elasticsearch.cli.ExitCodes;
+import org.elasticsearch.cli.Terminal;
+import org.elasticsearch.cli.UserException;
+import org.elasticsearch.common.SuppressForbidden;
+import org.elasticsearch.common.collect.Tuple;
+import org.elasticsearch.common.hash.MessageDigests;
+import org.elasticsearch.common.io.FileSystemUtils;
+import org.elasticsearch.env.Environment;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.URL;
+import java.net.URLConnection;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryStream;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.PosixFileAttributeView;
+import java.nio.file.attribute.PosixFileAttributes;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import static org.elasticsearch.cli.Terminal.Verbosity.VERBOSE;
+
+/**
+ * A command for the plugin cli to install a plugin into elasticsearch.
+ *
+ * The install command takes a plugin id, which may be any of the following:
+ * <ul>
+ * <li>An official elasticsearch plugin name</li>
+ * <li>Maven coordinates to a plugin zip</li>
+ * <li>A URL to a plugin zip</li>
+ * </ul>
+ *
+ * Plugins are packaged as zip files. Each packaged plugin must contain a
+ * plugin properties file. See {@link PluginInfo}.
+ * <p>
+ * The installation process first extracts the plugin files into a temporary
+ * directory in order to verify the plugin satisfies the following requirements:
+ * <ul>
+ * <li>Jar hell does not exist, either between the plugin's own jars, or with elasticsearch</li>
+ * <li>The plugin is not a module already provided with elasticsearch</li>
+ * <li>If the plugin contains extra security permissions, the policy file is validated</li>
+ * </ul>
+ * <p>
+ * A plugin may also contain an optional {@code bin} directory which contains scripts. The
+ * scripts will be installed into a subdirectory of the elasticsearch bin directory, using
+ * the name of the plugin, and the scripts will be marked executable.
+ * <p>
+ * A plugin may also contain an optional {@code config} directory which contains configuration
+ * files specific to the plugin. The config files be installed into a subdirectory of the
+ * elasticsearch config directory, using the name of the plugin. If any files to be installed
+ * already exist, they will be skipped.
+ */
+class InstallPluginCommand extends EnvironmentAwareCommand {
+
+ private static final String PROPERTY_STAGING_ID = "es.plugins.staging";
+
+ // exit codes for install
+ /** A plugin with the same name is already installed. */
+ static final int PLUGIN_EXISTS = 1;
+ /** The plugin zip is not properly structured. */
+ static final int PLUGIN_MALFORMED = 2;
+
+
+ /** The builtin modules, which are plugins, but cannot be installed or removed. */
+ static final Set<String> MODULES;
+ static {
+ try (InputStream stream = InstallPluginCommand.class.getResourceAsStream("/modules.txt");
+ BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
+ Set<String> modules = new HashSet<>();
+ String line = reader.readLine();
+ while (line != null) {
+ modules.add(line.trim());
+ line = reader.readLine();
+ }
+ MODULES = Collections.unmodifiableSet(modules);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /** The official plugins that can be installed simply by name. */
+ static final Set<String> OFFICIAL_PLUGINS;
+ static {
+ try (InputStream stream = InstallPluginCommand.class.getResourceAsStream("/plugins.txt");
+ BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
+ Set<String> plugins = new TreeSet<>(); // use tree set to get sorting for help command
+ String line = reader.readLine();
+ while (line != null) {
+ plugins.add(line.trim());
+ line = reader.readLine();
+ }
+ plugins.add("x-pack");
+ OFFICIAL_PLUGINS = Collections.unmodifiableSet(plugins);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private final OptionSpec<Void> batchOption;
+ private final OptionSpec<String> arguments;
+
+ static final Set<PosixFilePermission> BIN_DIR_PERMS;
+ static final Set<PosixFilePermission> BIN_FILES_PERMS;
+ static final Set<PosixFilePermission> CONFIG_DIR_PERMS;
+ static final Set<PosixFilePermission> CONFIG_FILES_PERMS;
+ static final Set<PosixFilePermission> PLUGIN_DIR_PERMS;
+ static final Set<PosixFilePermission> PLUGIN_FILES_PERMS;
+
+ static {
+ // Bin directory get chmod 755
+ BIN_DIR_PERMS = Collections.unmodifiableSet(PosixFilePermissions.fromString("rwxr-xr-x"));
+
+ // Bin files also get chmod 755
+ BIN_FILES_PERMS = BIN_DIR_PERMS;
+
+ // Config directory get chmod 750
+ CONFIG_DIR_PERMS = Collections.unmodifiableSet(PosixFilePermissions.fromString("rwxr-x---"));
+
+ // Config files get chmod 660
+ CONFIG_FILES_PERMS = Collections.unmodifiableSet(PosixFilePermissions.fromString("rw-rw----"));
+
+ // Plugin directory get chmod 755
+ PLUGIN_DIR_PERMS = BIN_DIR_PERMS;
+
+ // Plugins files get chmod 644
+ PLUGIN_FILES_PERMS = Collections.unmodifiableSet(PosixFilePermissions.fromString("rw-r--r--"));
+ }
+
+ InstallPluginCommand() {
+ super("Install a plugin");
+ this.batchOption = parser.acceptsAll(Arrays.asList("b", "batch"),
+ "Enable batch mode explicitly, automatic confirmation of security permission");
+ this.arguments = parser.nonOptions("plugin id");
+ }
+
+ @Override
+ protected void printAdditionalHelp(Terminal terminal) {
+ terminal.println("The following official plugins may be installed by name:");
+ for (String plugin : OFFICIAL_PLUGINS) {
+ terminal.println(" " + plugin);
+ }
+ terminal.println("");
+ }
+
+ @Override
+ protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception {
+ String pluginId = arguments.value(options);
+ boolean isBatch = options.has(batchOption) || System.console() == null;
+ execute(terminal, pluginId, isBatch, env);
+ }
+
+ // pkg private for testing
+ void execute(Terminal terminal, String pluginId, boolean isBatch, Environment env) throws Exception {
+ if (pluginId == null) {
+ throw new UserException(ExitCodes.USAGE, "plugin id is required");
+ }
+
+ Path pluginZip = download(terminal, pluginId, env.tmpFile());
+ Path extractedZip = unzip(pluginZip, env.pluginsFile());
+ install(terminal, isBatch, extractedZip, env);
+ }
+
+ /** Downloads the plugin and returns the file it was downloaded to. */
+ private Path download(Terminal terminal, String pluginId, Path tmpDir) throws Exception {
+ if (OFFICIAL_PLUGINS.contains(pluginId)) {
+ final String version = Version.CURRENT.toString();
+ final String url;
+ final String stagingHash = System.getProperty(PROPERTY_STAGING_ID);
+ if (stagingHash != null) {
+ url = String.format(Locale.ROOT,
+ "https://staging.elastic.co/%3$s-%1$s/downloads/elasticsearch-plugins/%2$s/%2$s-%3$s.zip",
+ stagingHash, pluginId, version);
+ } else {
+ url = String.format(Locale.ROOT,
+ "https://artifacts.elastic.co/downloads/elasticsearch-plugins/%1$s/%1$s-%2$s.zip",
+ pluginId, version);
+ }
+ terminal.println("-> Downloading " + pluginId + " from elastic");
+ return downloadZipAndChecksum(terminal, url, tmpDir);
+ }
+
+ // now try as maven coordinates, a valid URL would only have a colon and slash
+ String[] coordinates = pluginId.split(":");
+ if (coordinates.length == 3 && pluginId.contains("/") == false) {
+ String mavenUrl = String.format(Locale.ROOT, "https://repo1.maven.org/maven2/%1$s/%2$s/%3$s/%2$s-%3$s.zip",
+ coordinates[0].replace(".", "/") /* groupId */, coordinates[1] /* artifactId */, coordinates[2] /* version */);
+ terminal.println("-> Downloading " + pluginId + " from maven central");
+ return downloadZipAndChecksum(terminal, mavenUrl, tmpDir);
+ }
+
+ // fall back to plain old URL
+ if (pluginId.contains(":/") == false) {
+ // definitely not a valid url, so assume it is a plugin name
+ List<String> plugins = checkMisspelledPlugin(pluginId);
+ String msg = "Unknown plugin " + pluginId;
+ if (plugins.isEmpty() == false) {
+ msg += ", did you mean " + (plugins.size() == 1 ? "[" + plugins.get(0) + "]": "any of " + plugins.toString()) + "?";
+ }
+ throw new UserException(ExitCodes.USAGE, msg);
+ }
+ terminal.println("-> Downloading " + URLDecoder.decode(pluginId, "UTF-8"));
+ return downloadZip(terminal, pluginId, tmpDir);
+ }
+
+ /** Returns all the official plugin names that look similar to pluginId. **/
+ private List<String> checkMisspelledPlugin(String pluginId) {
+ LevensteinDistance ld = new LevensteinDistance();
+ List<Tuple<Float, String>> scoredKeys = new ArrayList<>();
+ for (String officialPlugin : OFFICIAL_PLUGINS) {
+ float distance = ld.getDistance(pluginId, officialPlugin);
+ if (distance > 0.7f) {
+ scoredKeys.add(new Tuple<>(distance, officialPlugin));
+ }
+ }
+ CollectionUtil.timSort(scoredKeys, (a, b) -> b.v1().compareTo(a.v1()));
+ return scoredKeys.stream().map((a) -> a.v2()).collect(Collectors.toList());
+ }
+
+ /** Downloads a zip from the url, into a temp file under the given temp dir. */
+ @SuppressForbidden(reason = "We use getInputStream to download plugins")
+ private Path downloadZip(Terminal terminal, String urlString, Path tmpDir) throws IOException {
+ terminal.println(VERBOSE, "Retrieving zip from " + urlString);
+ URL url = new URL(urlString);
+ Path zip = Files.createTempFile(tmpDir, null, ".zip");
+ URLConnection urlConnection = url.openConnection();
+ urlConnection.addRequestProperty("User-Agent", "elasticsearch-plugin-installer");
+ int contentLength = urlConnection.getContentLength();
+ try (InputStream in = new TerminalProgressInputStream(urlConnection.getInputStream(), contentLength, terminal)) {
+ // must overwrite since creating the temp file above actually created the file
+ Files.copy(in, zip, StandardCopyOption.REPLACE_EXISTING);
+ }
+ return zip;
+ }
+
+ /**
+ * content length might be -1 for unknown and progress only makes sense if the content length is greater than 0
+ */
+ private class TerminalProgressInputStream extends ProgressInputStream {
+
+ private final Terminal terminal;
+ private int width = 50;
+ private final boolean enabled;
+
+ TerminalProgressInputStream(InputStream is, int expectedTotalSize, Terminal terminal) {
+ super(is, expectedTotalSize);
+ this.terminal = terminal;
+ this.enabled = expectedTotalSize > 0;
+ }
+
+ @Override
+ public void onProgress(int percent) {
+ if (enabled) {
+ int currentPosition = percent * width / 100;
+ StringBuilder sb = new StringBuilder("\r[");
+ sb.append(String.join("=", Collections.nCopies(currentPosition, "")));
+ if (currentPosition > 0 && percent < 100) {
+ sb.append(">");
+ }
+ sb.append(String.join(" ", Collections.nCopies(width - currentPosition, "")));
+ sb.append("] %s   ");
+ if (percent == 100) {
+ sb.append("\n");
+ }
+ terminal.print(Terminal.Verbosity.NORMAL, String.format(Locale.ROOT, sb.toString(), percent + "%"));
+ }
+ }
+ }
+
+ /** Downloads a zip from the url, as well as a SHA1 checksum, and checks the checksum. */
+ @SuppressForbidden(reason = "We use openStream to download plugins")
+ private Path downloadZipAndChecksum(Terminal terminal, String urlString, Path tmpDir) throws Exception {
+ Path zip = downloadZip(terminal, urlString, tmpDir);
+ pathsToDeleteOnShutdown.add(zip);
+ URL checksumUrl = new URL(urlString + ".sha1");
+ final String expectedChecksum;
+ try (InputStream in = checksumUrl.openStream()) {
+ BufferedReader checksumReader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
+ expectedChecksum = checksumReader.readLine();
+ if (checksumReader.readLine() != null) {
+ throw new UserException(ExitCodes.IO_ERROR, "Invalid checksum file at " + checksumUrl);
+ }
+ }
+
+ byte[] zipbytes = Files.readAllBytes(zip);
+ String gotChecksum = MessageDigests.toHexString(MessageDigests.sha1().digest(zipbytes));
+ if (expectedChecksum.equals(gotChecksum) == false) {
+ throw new UserException(ExitCodes.IO_ERROR,
+ "SHA1 mismatch, expected " + expectedChecksum + " but got " + gotChecksum);
+ }
+
+ return zip;
+ }
+
+ private Path unzip(Path zip, Path pluginsDir) throws IOException, UserException {
+ // unzip plugin to a staging temp dir
+
+ final Path target = stagingDirectory(pluginsDir);
+ pathsToDeleteOnShutdown.add(target);
+
+ boolean hasEsDir = false;
+ try (ZipInputStream zipInput = new ZipInputStream(Files.newInputStream(zip))) {
+ ZipEntry entry;
+ byte[] buffer = new byte[8192];
+ while ((entry = zipInput.getNextEntry()) != null) {
+ if (entry.getName().startsWith("elasticsearch/") == false) {
+ // only extract the elasticsearch directory
+ continue;
+ }
+ hasEsDir = true;
+ Path targetFile = target.resolve(entry.getName().substring("elasticsearch/".length()));
+
+ // Using the entry name as a path can result in an entry outside of the plugin dir,
+ // either if the name starts with the root of the filesystem, or it is a relative
+ // entry like ../whatever. This check attempts to identify both cases by first
+ // normalizing the path (which removes foo/..) and ensuring the normalized entry
+ // is still rooted with the target plugin directory.
+ if (targetFile.normalize().startsWith(target) == false) {
+ throw new UserException(PLUGIN_MALFORMED, "Zip contains entry name '" +
+ entry.getName() + "' resolving outside of plugin directory");
+ }
+
+ // be on the safe side: do not rely on that directories are always extracted
+ // before their children (although this makes sense, but is it guaranteed?)
+ if (!Files.isSymbolicLink(targetFile.getParent())) {
+ Files.createDirectories(targetFile.getParent());
+ }
+ if (entry.isDirectory() == false) {
+ try (OutputStream out = Files.newOutputStream(targetFile)) {
+ int len;
+ while ((len = zipInput.read(buffer)) >= 0) {
+ out.write(buffer, 0, len);
+ }
+ }
+ }
+ zipInput.closeEntry();
+ }
+ }
+ Files.delete(zip);
+ if (hasEsDir == false) {
+ IOUtils.rm(target);
+ throw new UserException(PLUGIN_MALFORMED,
+ "`elasticsearch` directory is missing in the plugin zip");
+ }
+ return target;
+ }
+
+ private Path stagingDirectory(Path pluginsDir) throws IOException {
+ try {
+ return Files.createTempDirectory(pluginsDir, ".installing-", PosixFilePermissions.asFileAttribute(PLUGIN_DIR_PERMS));
+ } catch (IllegalArgumentException e) {
+ // Jimfs throws an IAE where it should throw an UOE
+ // remove when google/jimfs#30 is integrated into Jimfs
+ // and the Jimfs test dependency is upgraded to include
+ // this pull request
+ final StackTraceElement[] elements = e.getStackTrace();
+ if (elements.length >= 1 &&
+ elements[0].getClassName().equals("com.google.common.jimfs.AttributeService") &&
+ elements[0].getMethodName().equals("setAttributeInternal")) {
+ return stagingDirectoryWithoutPosixPermissions(pluginsDir);
+ } else {
+ throw e;
+ }
+ } catch (UnsupportedOperationException e) {
+ return stagingDirectoryWithoutPosixPermissions(pluginsDir);
+ }
+ }
+
+ private Path stagingDirectoryWithoutPosixPermissions(Path pluginsDir) throws IOException {
+ return Files.createTempDirectory(pluginsDir, ".installing-");
+ }
+
+ /** Load information about the plugin, and verify it can be installed with no errors. */
+ private PluginInfo verify(Terminal terminal, Path pluginRoot, boolean isBatch, Environment env) throws Exception {
+ // read and validate the plugin descriptor
+ PluginInfo info = PluginInfo.readFromProperties(pluginRoot);
+
+ // checking for existing version of the plugin
+ final Path destination = env.pluginsFile().resolve(info.getName());
+ if (Files.exists(destination)) {
+ final String message = String.format(
+ Locale.ROOT,
+ "plugin directory [%s] already exists; if you need to update the plugin, " +
+ "uninstall it first using command 'remove %s'",
+ destination.toAbsolutePath(),
+ info.getName());
+ throw new UserException(PLUGIN_EXISTS, message);
+ }
+
+ terminal.println(VERBOSE, info.toString());
+
+ // don't let user install plugin as a module...
+ // they might be unavoidably in maven central and are packaged up the same way)
+ if (MODULES.contains(info.getName())) {
+ throw new UserException(ExitCodes.USAGE, "plugin '" + info.getName() +
+ "' cannot be installed like this, it is a system module");
+ }
+
+ // check for jar hell before any copying
+ jarHellCheck(pluginRoot, env.pluginsFile());
+
+ // read optional security policy (extra permissions)
+ // if it exists, confirm or warn the user
+ Path policy = pluginRoot.resolve(PluginInfo.ES_PLUGIN_POLICY);
+ if (Files.exists(policy)) {
+ PluginSecurity.readPolicy(info, policy, terminal, env::tmpFile, isBatch);
+ }
+
+ return info;
+ }
+
+ /** check a candidate plugin for jar hell before installing it */
+ void jarHellCheck(Path candidate, Path pluginsDir) throws Exception {
+ // create list of current jars in classpath
+ final Set<URL> jars = new HashSet<>(JarHell.parseClassPath());
+
+ // read existing bundles. this does some checks on the installation too.
+ PluginsService.getPluginBundles(pluginsDir);
+
+ // add plugin jars to the list
+ Path pluginJars[] = FileSystemUtils.files(candidate, "*.jar");
+ for (Path jar : pluginJars) {
+ if (jars.add(jar.toUri().toURL()) == false) {
+ throw new IllegalStateException("jar hell! duplicate plugin jar: " + jar);
+ }
+ }
+ // TODO: no jars should be an error
+ // TODO: verify the classname exists in one of the jars!
+
+ // check combined (current classpath + new jars to-be-added)
+ JarHell.checkJarHell(jars);
+ }
+
+ /**
+ * Installs the plugin from {@code tmpRoot} into the plugins dir.
+ * If the plugin has a bin dir and/or a config dir, those are copied.
+ */
+ private void install(Terminal terminal, boolean isBatch, Path tmpRoot, Environment env) throws Exception {
+ List<Path> deleteOnFailure = new ArrayList<>();
+ deleteOnFailure.add(tmpRoot);
+
+ try {
+ PluginInfo info = verify(terminal, tmpRoot, isBatch, env);
+ final Path destination = env.pluginsFile().resolve(info.getName());
+
+ Path tmpBinDir = tmpRoot.resolve("bin");
+ if (Files.exists(tmpBinDir)) {
+ Path destBinDir = env.binFile().resolve(info.getName());
+ deleteOnFailure.add(destBinDir);
+ installBin(info, tmpBinDir, destBinDir);
+ }
+
+ Path tmpConfigDir = tmpRoot.resolve("config");
+ if (Files.exists(tmpConfigDir)) {
+ // some files may already exist, and we don't remove plugin config files on plugin removal,
+ // so any installed config files are left on failure too
+ installConfig(info, tmpConfigDir, env.configFile().resolve(info.getName()));
+ }
+
+ Files.move(tmpRoot, destination, StandardCopyOption.ATOMIC_MOVE);
+ Files.walkFileTree(destination, new SimpleFileVisitor<Path>() {
+ @Override
+ public FileVisitResult visitFile(Path pluginFile, BasicFileAttributes attrs) throws IOException {
+ if (Files.isDirectory(pluginFile)) {
+ setFileAttributes(pluginFile, PLUGIN_DIR_PERMS);
+ } else {
+ // There can also be "bin" directories under the plugin directory, storing native code executables
+ Path parentDir = pluginFile.getParent().getFileName();
+ if ("bin".equals(parentDir.toString())) {
+ setFileAttributes(pluginFile, BIN_FILES_PERMS);
+ } else {
+ setFileAttributes(pluginFile, PLUGIN_FILES_PERMS);
+ }
+ }
+ return FileVisitResult.CONTINUE;
+ }
+ });
+
+ terminal.println("-> Installed " + info.getName());
+
+ } catch (Exception installProblem) {
+ try {
+ IOUtils.rm(deleteOnFailure.toArray(new Path[0]));
+ } catch (IOException exceptionWhileRemovingFiles) {
+ installProblem.addSuppressed(exceptionWhileRemovingFiles);
+ }
+ throw installProblem;
+ }
+ }
+
+ /** Copies the files from {@code tmpBinDir} into {@code destBinDir}, along with permissions from dest dirs parent. */
+ private void installBin(PluginInfo info, Path tmpBinDir, Path destBinDir) throws Exception {
+ if (Files.isDirectory(tmpBinDir) == false) {
+ throw new UserException(PLUGIN_MALFORMED, "bin in plugin " + info.getName() + " is not a directory");
+ }
+ Files.createDirectory(destBinDir);
+ setFileAttributes(destBinDir, BIN_DIR_PERMS);
+
+ try (DirectoryStream<Path> stream = Files.newDirectoryStream(tmpBinDir)) {
+ for (Path srcFile : stream) {
+ if (Files.isDirectory(srcFile)) {
+ throw new UserException(PLUGIN_MALFORMED, "Directories not allowed in bin dir " +
+ "for plugin " + info.getName() + ", found " + srcFile.getFileName());
+ }
+
+ Path destFile = destBinDir.resolve(tmpBinDir.relativize(srcFile));
+ Files.copy(srcFile, destFile);
+ setFileAttributes(destFile, BIN_FILES_PERMS);
+ }
+ }
+ IOUtils.rm(tmpBinDir); // clean up what we just copied
+ }
+
+ /**
+ * Copies the files from {@code tmpConfigDir} into {@code destConfigDir}.
+ * Any files existing in both the source and destination will be skipped.
+ */
+ private void installConfig(PluginInfo info, Path tmpConfigDir, Path destConfigDir) throws Exception {
+ if (Files.isDirectory(tmpConfigDir) == false) {
+ throw new UserException(PLUGIN_MALFORMED,
+ "config in plugin " + info.getName() + " is not a directory");
+ }
+
+ Files.createDirectories(destConfigDir);
+ setFileAttributes(destConfigDir, CONFIG_DIR_PERMS);
+ final PosixFileAttributeView destConfigDirAttributesView =
+ Files.getFileAttributeView(destConfigDir.getParent(), PosixFileAttributeView.class);
+ final PosixFileAttributes destConfigDirAttributes =
+ destConfigDirAttributesView != null ? destConfigDirAttributesView.readAttributes() : null;
+ if (destConfigDirAttributes != null) {
+ setOwnerGroup(destConfigDir, destConfigDirAttributes);
+ }
+
+ try (DirectoryStream<Path> stream = Files.newDirectoryStream(tmpConfigDir)) {
+ for (Path srcFile : stream) {
+ if (Files.isDirectory(srcFile)) {
+ throw new UserException(PLUGIN_MALFORMED,
+ "Directories not allowed in config dir for plugin " + info.getName());
+ }
+
+ Path destFile = destConfigDir.resolve(tmpConfigDir.relativize(srcFile));
+ if (Files.exists(destFile) == false) {
+ Files.copy(srcFile, destFile);
+ setFileAttributes(destFile, CONFIG_FILES_PERMS);
+ if (destConfigDirAttributes != null) {
+ setOwnerGroup(destFile, destConfigDirAttributes);
+ }
+ }
+ }
+ }
+ IOUtils.rm(tmpConfigDir); // clean up what we just copied
+ }
+
+ private static void setOwnerGroup(final Path path, final PosixFileAttributes attributes) throws IOException {
+ Objects.requireNonNull(attributes);
+ PosixFileAttributeView fileAttributeView = Files.getFileAttributeView(path, PosixFileAttributeView.class);
+ assert fileAttributeView != null;
+ fileAttributeView.setOwner(attributes.owner());
+ fileAttributeView.setGroup(attributes.group());
+ }
+
+ /**
+ * Sets the attributes for a path iff posix attributes are supported
+ */
+ private static void setFileAttributes(final Path path, final Set<PosixFilePermission> permissions) throws IOException {
+ PosixFileAttributeView fileAttributeView = Files.getFileAttributeView(path, PosixFileAttributeView.class);
+ if (fileAttributeView != null) {
+ Files.setPosixFilePermissions(path, permissions);
+ }
+ }
+
+ private final List<Path> pathsToDeleteOnShutdown = new ArrayList<>();
+
+ @Override
+ public void close() throws IOException {
+ IOUtils.rm(pathsToDeleteOnShutdown.toArray(new Path[pathsToDeleteOnShutdown.size()]));
+ }
+
+}
diff --git a/distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/ListPluginsCommand.java b/distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/ListPluginsCommand.java
new file mode 100644
index 0000000000..c2b5ce34b5
--- /dev/null
+++ b/distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/ListPluginsCommand.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you 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 org.elasticsearch.plugins;
+
+import joptsimple.OptionSet;
+import org.elasticsearch.cli.EnvironmentAwareCommand;
+import org.elasticsearch.cli.Terminal;
+import org.elasticsearch.env.Environment;
+
+import java.io.IOException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A command for the plugin cli to list plugins installed in elasticsearch.
+ */
+class ListPluginsCommand extends EnvironmentAwareCommand {
+
+ ListPluginsCommand() {
+ super("Lists installed elasticsearch plugins");
+ }
+
+ @Override
+ protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception {
+ if (Files.exists(env.pluginsFile()) == false) {
+ throw new IOException("Plugins directory missing: " + env.pluginsFile());
+ }
+
+ terminal.println(Terminal.Verbosity.VERBOSE, "Plugins directory: " + env.pluginsFile());
+ final List<Path> plugins = new ArrayList<>();
+ try (DirectoryStream<Path> paths = Files.newDirectoryStream(env.pluginsFile())) {
+ for (Path plugin : paths) {
+ plugins.add(plugin);
+ }
+ }
+ Collections.sort(plugins);
+ for (final Path plugin : plugins) {
+ terminal.println(Terminal.Verbosity.SILENT, plugin.getFileName().toString());
+ try {
+ PluginInfo info = PluginInfo.readFromProperties(env.pluginsFile().resolve(plugin.toAbsolutePath()));
+ terminal.println(Terminal.Verbosity.VERBOSE, info.toString());
+ } catch (IllegalArgumentException e) {
+ if (e.getMessage().contains("incompatible with version")) {
+ terminal.println("WARNING: " + e.getMessage());
+ } else {
+ throw e;
+ }
+ }
+ }
+ }
+}
diff --git a/distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/PluginCli.java b/distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/PluginCli.java
new file mode 100644
index 0000000000..ccc96c94eb
--- /dev/null
+++ b/distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/PluginCli.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you 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 org.elasticsearch.plugins;
+
+import org.apache.lucene.util.IOUtils;
+import org.elasticsearch.cli.Command;
+import org.elasticsearch.cli.MultiCommand;
+import org.elasticsearch.cli.Terminal;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+
+/**
+ * A cli tool for adding, removing and listing plugins for elasticsearch.
+ */
+public class PluginCli extends MultiCommand {
+
+ private final Collection<Command> commands;
+
+ private PluginCli() {
+ super("A tool for managing installed elasticsearch plugins");
+ subcommands.put("list", new ListPluginsCommand());
+ subcommands.put("install", new InstallPluginCommand());
+ subcommands.put("remove", new RemovePluginCommand());
+ commands = Collections.unmodifiableCollection(subcommands.values());
+ }
+
+ public static void main(String[] args) throws Exception {
+ exit(new PluginCli().main(args, Terminal.DEFAULT));
+ }
+
+ @Override
+ public void close() throws IOException {
+ IOUtils.close(commands);
+ }
+
+}
diff --git a/distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/ProgressInputStream.java b/distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/ProgressInputStream.java
new file mode 100644
index 0000000000..64d9437fa2
--- /dev/null
+++ b/distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/ProgressInputStream.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you 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 org.elasticsearch.plugins;
+
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * An input stream that allows to add a listener to monitor progress
+ * The listener is triggered whenever a full percent is increased
+ * The listener is never triggered twice on the same percentage
+ * The listener will always return 99 percent, if the expectedTotalSize is exceeded, until it is finished
+ *
+ * Only used by the InstallPluginCommand, thus package private here
+ */
+abstract class ProgressInputStream extends FilterInputStream {
+
+ private final int expectedTotalSize;
+ private int currentPercent;
+ private int count = 0;
+
+ ProgressInputStream(InputStream is, int expectedTotalSize) {
+ super(is);
+ this.expectedTotalSize = expectedTotalSize;
+ this.currentPercent = 0;
+ }
+
+ @Override
+ public int read() throws IOException {
+ int read = in.read();
+ checkProgress(read == -1 ? -1 : 1);
+ return read;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ int byteCount = super.read(b, off, len);
+ checkProgress(byteCount);
+ return byteCount;
+ }
+
+ @Override
+ public int read(byte b[]) throws IOException {
+ return read(b, 0, b.length);
+ }
+
+ void checkProgress(int byteCount) {
+ // are we done?
+ if (byteCount == -1) {
+ currentPercent = 100;
+ onProgress(currentPercent);
+ } else {
+ count += byteCount;
+ // rounding up to 100% would mean we say we are done, before we are...
+ // this also catches issues, when expectedTotalSize was guessed wrong
+ int percent = Math.min(99, (int) Math.floor(100.0*count/expectedTotalSize));
+ if (percent > currentPercent) {
+ currentPercent = percent;
+ onProgress(percent);
+ }
+ }
+ }
+
+ public void onProgress(int percent) {}
+}
diff --git a/distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/RemovePluginCommand.java b/distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/RemovePluginCommand.java
new file mode 100644
index 0000000000..8e81f97d84
--- /dev/null
+++ b/distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/RemovePluginCommand.java
@@ -0,0 +1,134 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you 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 org.elasticsearch.plugins;
+
+import java.io.IOException;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import joptsimple.OptionSet;
+import joptsimple.OptionSpec;
+import org.apache.lucene.util.IOUtils;
+import org.elasticsearch.cli.EnvironmentAwareCommand;
+import org.elasticsearch.cli.ExitCodes;
+import org.elasticsearch.cli.Terminal;
+import org.elasticsearch.cli.UserException;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.env.Environment;
+
+import static org.elasticsearch.cli.Terminal.Verbosity.VERBOSE;
+
+/**
+ * A command for the plugin CLI to remove a plugin from Elasticsearch.
+ */
+class RemovePluginCommand extends EnvironmentAwareCommand {
+
+ private final OptionSpec<String> arguments;
+
+ RemovePluginCommand() {
+ super("removes a plugin from Elasticsearch");
+ this.arguments = parser.nonOptions("plugin name");
+ }
+
+ @Override
+ protected void execute(final Terminal terminal, final OptionSet options, final Environment env)
+ throws Exception {
+ final String pluginName = arguments.value(options);
+ execute(terminal, pluginName, env);
+ }
+
+ /**
+ * Remove the plugin specified by {@code pluginName}.
+ *
+ * @param terminal the terminal to use for input/output
+ * @param pluginName the name of the plugin to remove
+ * @param env the environment for the local node
+ * @throws IOException if any I/O exception occurs while performing a file operation
+ * @throws UserException if plugin name is null
+ * @throws UserException if plugin directory does not exist
+ * @throws UserException if the plugin bin directory is not a directory
+ */
+ void execute(final Terminal terminal, final String pluginName, final Environment env)
+ throws IOException, UserException {
+ if (pluginName == null) {
+ throw new UserException(ExitCodes.USAGE, "plugin name is required");
+ }
+
+ terminal.println("-> removing [" + Strings.coalesceToEmpty(pluginName) + "]...");
+
+ final Path pluginDir = env.pluginsFile().resolve(pluginName);
+ if (Files.exists(pluginDir) == false) {
+ final String message = String.format(
+ Locale.ROOT,
+ "plugin [%s] not found; "
+ + "run 'elasticsearch-plugin list' to get list of installed plugins",
+ pluginName);
+ throw new UserException(ExitCodes.CONFIG, message);
+ }
+
+ final List<Path> pluginPaths = new ArrayList<>();
+
+ final Path pluginBinDir = env.binFile().resolve(pluginName);
+ if (Files.exists(pluginBinDir)) {
+ if (Files.isDirectory(pluginBinDir) == false) {
+ throw new UserException(
+ ExitCodes.IO_ERROR, "bin dir for " + pluginName + " is not a directory");
+ }
+ pluginPaths.add(pluginBinDir);
+ terminal.println(VERBOSE, "removing [" + pluginBinDir + "]");
+ }
+
+ terminal.println(VERBOSE, "removing [" + pluginDir + "]");
+ final Path tmpPluginDir = env.pluginsFile().resolve(".removing-" + pluginName);
+ try {
+ Files.move(pluginDir, tmpPluginDir, StandardCopyOption.ATOMIC_MOVE);
+ } catch (final AtomicMoveNotSupportedException e) {
+ /*
+ * On a union file system if the plugin that we are removing is not installed on the
+ * top layer then atomic move will not be supported. In this case, we fall back to a
+ * non-atomic move.
+ */
+ Files.move(pluginDir, tmpPluginDir);
+ }
+ pluginPaths.add(tmpPluginDir);
+
+ IOUtils.rm(pluginPaths.toArray(new Path[pluginPaths.size()]));
+
+ /*
+ * We preserve the config files in case the user is upgrading the plugin, but we print a
+ * message so the user knows in case they want to remove manually.
+ */
+ final Path pluginConfigDir = env.configFile().resolve(pluginName);
+ if (Files.exists(pluginConfigDir)) {
+ final String message = String.format(
+ Locale.ROOT,
+ "-> preserving plugin config files [%s] in case of upgrade; "
+ + "delete manually if not needed",
+ pluginConfigDir);
+ terminal.println(message);
+ }
+ }
+
+}
diff --git a/distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/InstallPluginCommandTests.java b/distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/InstallPluginCommandTests.java
new file mode 100644
index 0000000000..a70b8a8d3d
--- /dev/null
+++ b/distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/InstallPluginCommandTests.java
@@ -0,0 +1,693 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you 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 org.elasticsearch.plugins;
+
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+import com.google.common.jimfs.Configuration;
+import com.google.common.jimfs.Jimfs;
+import org.apache.lucene.util.LuceneTestCase;
+import org.elasticsearch.Version;
+import org.elasticsearch.cli.MockTerminal;
+import org.elasticsearch.cli.Terminal;
+import org.elasticsearch.cli.UserException;
+import org.elasticsearch.common.SuppressForbidden;
+import org.elasticsearch.common.collect.Tuple;
+import org.elasticsearch.common.io.FileSystemUtils;
+import org.elasticsearch.common.io.PathUtils;
+import org.elasticsearch.common.io.PathUtilsForTesting;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.test.PosixPermissionsResetter;
+import org.junit.After;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StringReader;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryStream;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.FileSystem;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.GroupPrincipal;
+import java.nio.file.attribute.PosixFileAttributeView;
+import java.nio.file.attribute.PosixFileAttributes;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.UserPrincipal;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.not;
+
+@LuceneTestCase.SuppressFileSystems("*")
+public class InstallPluginCommandTests extends ESTestCase {
+
+ private final Function<String, Path> temp;
+
+ private final FileSystem fs;
+ private final boolean isPosix;
+ private final boolean isReal;
+ private final String javaIoTmpdir;
+
+ @SuppressForbidden(reason = "sets java.io.tmpdir")
+ public InstallPluginCommandTests(FileSystem fs, Function<String, Path> temp) {
+ this.fs = fs;
+ this.temp = temp;
+ this.isPosix = fs.supportedFileAttributeViews().contains("posix");
+ this.isReal = fs == PathUtils.getDefaultFileSystem();
+ PathUtilsForTesting.installMock(fs);
+ javaIoTmpdir = System.getProperty("java.io.tmpdir");
+ System.setProperty("java.io.tmpdir", temp.apply("tmpdir").toString());
+ }
+
+ @After
+ @SuppressForbidden(reason = "resets java.io.tmpdir")
+ public void tearDown() throws Exception {
+ System.setProperty("java.io.tmpdir", javaIoTmpdir);
+ PathUtilsForTesting.teardown();
+ super.tearDown();
+ }
+
+ @ParametersFactory
+ public static Iterable<Object[]> parameters() {
+ class Parameter {
+ private final FileSystem fileSystem;
+ private final Function<String, Path> temp;
+
+ Parameter(FileSystem fileSystem, String root) {
+ this(fileSystem, s -> {
+ try {
+ return Files.createTempDirectory(fileSystem.getPath(root), s);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ Parameter(FileSystem fileSystem, Function<String, Path> temp) {
+ this.fileSystem = fileSystem;
+ this.temp = temp;
+ }
+ }
+ List<Parameter> parameters = new ArrayList<>();
+ parameters.add(new Parameter(Jimfs.newFileSystem(Configuration.windows()), "c:\\"));
+ parameters.add(new Parameter(Jimfs.newFileSystem(toPosix(Configuration.osX())), "/"));
+ parameters.add(new Parameter(Jimfs.newFileSystem(toPosix(Configuration.unix())), "/"));
+ parameters.add(new Parameter(PathUtils.getDefaultFileSystem(), LuceneTestCase::createTempDir ));
+ return parameters.stream().map(p -> new Object[] { p.fileSystem, p.temp }).collect(Collectors.toList());
+ }
+
+ private static Configuration toPosix(Configuration configuration) {
+ return configuration.toBuilder().setAttributeViews("basic", "owner", "posix", "unix").build();
+ }
+
+ /** Creates a test environment with bin, config and plugins directories. */
+ static Tuple<Path, Environment> createEnv(FileSystem fs, Function<String, Path> temp) throws IOException {
+ Path home = temp.apply("install-plugin-command-tests");
+ Files.createDirectories(home.resolve("bin"));
+ Files.createFile(home.resolve("bin").resolve("elasticsearch"));
+ Files.createDirectories(home.resolve("config"));
+ Files.createFile(home.resolve("config").resolve("elasticsearch.yml"));
+ Path plugins = Files.createDirectories(home.resolve("plugins"));
+ assertTrue(Files.exists(plugins));
+ Settings settings = Settings.builder()
+ .put("path.home", home)
+ .build();
+ return Tuple.tuple(home, new Environment(settings));
+ }
+
+ static Path createPluginDir(Function<String, Path> temp) throws IOException {
+ return temp.apply("pluginDir");
+ }
+
+ /** creates a fake jar file with empty class files */
+ static void writeJar(Path jar, String... classes) throws IOException {
+ try (ZipOutputStream stream = new ZipOutputStream(Files.newOutputStream(jar))) {
+ for (String clazz : classes) {
+ stream.putNextEntry(new ZipEntry(clazz + ".class")); // no package names, just support simple classes
+ }
+ }
+ }
+
+ static String writeZip(Path structure, String prefix) throws IOException {
+ Path zip = createTempDir().resolve(structure.getFileName() + ".zip");
+ try (ZipOutputStream stream = new ZipOutputStream(Files.newOutputStream(zip))) {
+ Files.walkFileTree(structure, new SimpleFileVisitor<Path>() {
+ @Override
+ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
+ String target = (prefix == null ? "" : prefix + "/") + structure.relativize(file).toString();
+ stream.putNextEntry(new ZipEntry(target));
+ Files.copy(file, stream);
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ }
+ return zip.toUri().toURL().toString();
+ }
+
+ /** creates a plugin .zip and returns the url for testing */
+ static String createPlugin(String name, Path structure) throws IOException {
+ return createPlugin(name, structure, false);
+ }
+
+ static String createPlugin(String name, Path structure, boolean createSecurityPolicyFile) throws IOException {
+ PluginTestUtil.writeProperties(structure,
+ "description", "fake desc",
+ "name", name,
+ "version", "1.0",
+ "elasticsearch.version", Version.CURRENT.toString(),
+ "java.version", System.getProperty("java.specification.version"),
+ "classname", "FakePlugin");
+ if (createSecurityPolicyFile) {
+ String securityPolicyContent = "grant {\n permission java.lang.RuntimePermission \"setFactory\";\n};\n";
+ Files.write(structure.resolve("plugin-security.policy"), securityPolicyContent.getBytes(StandardCharsets.UTF_8));
+ }
+ writeJar(structure.resolve("plugin.jar"), "FakePlugin");
+ return writeZip(structure, "elasticsearch");
+ }
+
+ static MockTerminal installPlugin(String pluginUrl, Path home) throws Exception {
+ return installPlugin(pluginUrl, home, false);
+ }
+
+ static MockTerminal installPlugin(String pluginUrl, Path home, boolean jarHellCheck) throws Exception {
+ Environment env = new Environment(Settings.builder().put("path.home", home).build());
+ MockTerminal terminal = new MockTerminal();
+ new InstallPluginCommand() {
+ @Override
+ void jarHellCheck(Path candidate, Path pluginsDir) throws Exception {
+ if (jarHellCheck) {
+ super.jarHellCheck(candidate, pluginsDir);
+ }
+ }
+ }.execute(terminal, pluginUrl, true, env);
+ return terminal;
+ }
+
+ void assertPlugin(String name, Path original, Environment env) throws IOException {
+ Path got = env.pluginsFile().resolve(name);
+ assertTrue("dir " + name + " exists", Files.exists(got));
+
+ if (isPosix) {
+ Set<PosixFilePermission> perms = Files.getPosixFilePermissions(got);
+ assertThat(
+ perms,
+ containsInAnyOrder(
+ PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE,
+ PosixFilePermission.OWNER_EXECUTE,
+ PosixFilePermission.GROUP_READ,
+ PosixFilePermission.GROUP_EXECUTE,
+ PosixFilePermission.OTHERS_READ,
+ PosixFilePermission.OTHERS_EXECUTE));
+ }
+
+ assertTrue("jar was copied", Files.exists(got.resolve("plugin.jar")));
+ assertFalse("bin was not copied", Files.exists(got.resolve("bin")));
+ assertFalse("config was not copied", Files.exists(got.resolve("config")));
+ if (Files.exists(original.resolve("bin"))) {
+ Path binDir = env.binFile().resolve(name);
+ assertTrue("bin dir exists", Files.exists(binDir));
+ assertTrue("bin is a dir", Files.isDirectory(binDir));
+ PosixFileAttributes binAttributes = null;
+ if (isPosix) {
+ binAttributes = Files.readAttributes(env.binFile(), PosixFileAttributes.class);
+ }
+ try (DirectoryStream<Path> stream = Files.newDirectoryStream(binDir)) {
+ for (Path file : stream) {
+ assertFalse("not a dir", Files.isDirectory(file));
+ if (isPosix) {
+ PosixFileAttributes attributes = Files.readAttributes(file, PosixFileAttributes.class);
+ assertEquals(InstallPluginCommand.BIN_FILES_PERMS, attributes.permissions());
+ }
+ }
+ }
+ }
+ if (Files.exists(original.resolve("config"))) {
+ Path configDir = env.configFile().resolve(name);
+ assertTrue("config dir exists", Files.exists(configDir));
+ assertTrue("config is a dir", Files.isDirectory(configDir));
+
+ UserPrincipal user = null;
+ GroupPrincipal group = null;
+
+ if (isPosix) {
+ PosixFileAttributes configAttributes =
+ Files.getFileAttributeView(env.configFile(), PosixFileAttributeView.class).readAttributes();
+ user = configAttributes.owner();
+ group = configAttributes.group();
+
+ PosixFileAttributes attributes = Files.getFileAttributeView(configDir, PosixFileAttributeView.class).readAttributes();
+ assertThat(attributes.owner(), equalTo(user));
+ assertThat(attributes.group(), equalTo(group));
+ }
+
+ try (DirectoryStream<Path> stream = Files.newDirectoryStream(configDir)) {
+ for (Path file : stream) {
+ assertFalse("not a dir", Files.isDirectory(file));
+
+ if (isPosix) {
+ PosixFileAttributes attributes = Files.readAttributes(file, PosixFileAttributes.class);
+ if (user != null) {
+ assertThat(attributes.owner(), equalTo(user));
+ }
+ if (group != null) {
+ assertThat(attributes.group(), equalTo(group));
+ }
+ }
+ }
+ }
+ }
+ assertInstallCleaned(env);
+ }
+
+ void assertInstallCleaned(Environment env) throws IOException {
+ try (DirectoryStream<Path> stream = Files.newDirectoryStream(env.pluginsFile())) {
+ for (Path file : stream) {
+ if (file.getFileName().toString().startsWith(".installing")) {
+ fail("Installation dir still exists, " + file);
+ }
+ }
+ }
+ }
+
+ public void testMissingPluginId() throws IOException {
+ final Tuple<Path, Environment> env = createEnv(fs, temp);
+ final UserException e = expectThrows(UserException.class, () -> installPlugin(null, env.v1()));
+ assertTrue(e.getMessage(), e.getMessage().contains("plugin id is required"));
+ }
+
+ public void testSomethingWorks() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ String pluginZip = createPlugin("fake", pluginDir);
+ installPlugin(pluginZip, env.v1());
+ assertPlugin("fake", pluginDir, env.v2());
+ }
+
+ public void testSpaceInUrl() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ String pluginZip = createPlugin("fake", pluginDir);
+ Path pluginZipWithSpaces = createTempFile("foo bar", ".zip");
+ try (InputStream in = FileSystemUtils.openFileURLStream(new URL(pluginZip))) {
+ Files.copy(in, pluginZipWithSpaces, StandardCopyOption.REPLACE_EXISTING);
+ }
+ installPlugin(pluginZipWithSpaces.toUri().toURL().toString(), env.v1());
+ assertPlugin("fake", pluginDir, env.v2());
+ }
+
+ public void testMalformedUrlNotMaven() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ // has two colons, so it appears similar to maven coordinates
+ MalformedURLException e = expectThrows(MalformedURLException.class, () -> installPlugin("://host:1234", env.v1()));
+ assertTrue(e.getMessage(), e.getMessage().contains("no protocol"));
+ }
+
+ public void testUnknownPlugin() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ UserException e = expectThrows(UserException.class, () -> installPlugin("foo", env.v1()));
+ assertTrue(e.getMessage(), e.getMessage().contains("Unknown plugin foo"));
+ }
+
+ public void testPluginsDirReadOnly() throws Exception {
+ assumeTrue("posix and filesystem", isPosix && isReal);
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ try (PosixPermissionsResetter pluginsAttrs = new PosixPermissionsResetter(env.v2().pluginsFile())) {
+ pluginsAttrs.setPermissions(new HashSet<>());
+ String pluginZip = createPlugin("fake", pluginDir);
+ IOException e = expectThrows(IOException.class, () -> installPlugin(pluginZip, env.v1()));
+ assertTrue(e.getMessage(), e.getMessage().contains(env.v2().pluginsFile().toString()));
+ }
+ assertInstallCleaned(env.v2());
+ }
+
+ public void testBuiltinModule() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ String pluginZip = createPlugin("lang-painless", pluginDir);
+ UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip, env.v1()));
+ assertTrue(e.getMessage(), e.getMessage().contains("is a system module"));
+ assertInstallCleaned(env.v2());
+ }
+
+ public void testJarHell() throws Exception {
+ // jar hell test needs a real filesystem
+ assumeTrue("real filesystem", isReal);
+ Tuple<Path, Environment> environment = createEnv(fs, temp);
+ Path pluginDirectory = createPluginDir(temp);
+ writeJar(pluginDirectory.resolve("other.jar"), "FakePlugin");
+ String pluginZip = createPlugin("fake", pluginDirectory); // adds plugin.jar with FakePlugin
+ IllegalStateException e = expectThrows(IllegalStateException.class, () -> installPlugin(pluginZip, environment.v1(), true));
+ assertTrue(e.getMessage(), e.getMessage().contains("jar hell"));
+ assertInstallCleaned(environment.v2());
+ }
+
+ public void testIsolatedPlugins() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ // these both share the same FakePlugin class
+ Path pluginDir1 = createPluginDir(temp);
+ String pluginZip1 = createPlugin("fake1", pluginDir1);
+ installPlugin(pluginZip1, env.v1());
+ Path pluginDir2 = createPluginDir(temp);
+ String pluginZip2 = createPlugin("fake2", pluginDir2);
+ installPlugin(pluginZip2, env.v1());
+ assertPlugin("fake1", pluginDir1, env.v2());
+ assertPlugin("fake2", pluginDir2, env.v2());
+ }
+
+ public void testExistingPlugin() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ String pluginZip = createPlugin("fake", pluginDir);
+ installPlugin(pluginZip, env.v1());
+ UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip, env.v1()));
+ assertTrue(e.getMessage(), e.getMessage().contains("already exists"));
+ assertInstallCleaned(env.v2());
+ }
+
+ public void testBin() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ Path binDir = pluginDir.resolve("bin");
+ Files.createDirectory(binDir);
+ Files.createFile(binDir.resolve("somescript"));
+ String pluginZip = createPlugin("fake", pluginDir);
+ installPlugin(pluginZip, env.v1());
+ assertPlugin("fake", pluginDir, env.v2());
+ }
+
+ public void testBinNotDir() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ Path binDir = pluginDir.resolve("bin");
+ Files.createFile(binDir);
+ String pluginZip = createPlugin("fake", pluginDir);
+ UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip, env.v1()));
+ assertTrue(e.getMessage(), e.getMessage().contains("not a directory"));
+ assertInstallCleaned(env.v2());
+ }
+
+ public void testBinContainsDir() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ Path dirInBinDir = pluginDir.resolve("bin").resolve("foo");
+ Files.createDirectories(dirInBinDir);
+ Files.createFile(dirInBinDir.resolve("somescript"));
+ String pluginZip = createPlugin("fake", pluginDir);
+ UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip, env.v1()));
+ assertTrue(e.getMessage(), e.getMessage().contains("Directories not allowed in bin dir for plugin"));
+ assertInstallCleaned(env.v2());
+ }
+
+ public void testBinConflict() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ Path binDir = pluginDir.resolve("bin");
+ Files.createDirectory(binDir);
+ Files.createFile(binDir.resolve("somescript"));
+ String pluginZip = createPlugin("elasticsearch", pluginDir);
+ FileAlreadyExistsException e = expectThrows(FileAlreadyExistsException.class, () -> installPlugin(pluginZip, env.v1()));
+ assertTrue(e.getMessage(), e.getMessage().contains(env.v2().binFile().resolve("elasticsearch").toString()));
+ assertInstallCleaned(env.v2());
+ }
+
+ public void testBinPermissions() throws Exception {
+ assumeTrue("posix filesystem", isPosix);
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ Path binDir = pluginDir.resolve("bin");
+ Files.createDirectory(binDir);
+ Files.createFile(binDir.resolve("somescript"));
+ String pluginZip = createPlugin("fake", pluginDir);
+ try (PosixPermissionsResetter binAttrs = new PosixPermissionsResetter(env.v2().binFile())) {
+ Set<PosixFilePermission> perms = binAttrs.getCopyPermissions();
+ // make sure at least one execute perm is missing, so we know we forced it during installation
+ perms.remove(PosixFilePermission.GROUP_EXECUTE);
+ binAttrs.setPermissions(perms);
+ installPlugin(pluginZip, env.v1());
+ assertPlugin("fake", pluginDir, env.v2());
+ }
+ }
+
+ public void testPlatformBinPermissions() throws Exception {
+ assumeTrue("posix filesystem", isPosix);
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ Path platformDir = pluginDir.resolve("platform");
+ Path platformNameDir = platformDir.resolve("linux-x86_64");
+ Path platformBinDir = platformNameDir.resolve("bin");
+ Files.createDirectories(platformBinDir);
+ Path programFile = Files.createFile(platformBinDir.resolve("someprogram"));
+ // a file created with Files.createFile() should not have execute permissions
+ Set<PosixFilePermission> sourcePerms = Files.getPosixFilePermissions(programFile);
+ assertFalse(sourcePerms.contains(PosixFilePermission.OWNER_EXECUTE));
+ assertFalse(sourcePerms.contains(PosixFilePermission.GROUP_EXECUTE));
+ assertFalse(sourcePerms.contains(PosixFilePermission.OTHERS_EXECUTE));
+ String pluginZip = createPlugin("fake", pluginDir);
+ installPlugin(pluginZip, env.v1());
+ assertPlugin("fake", pluginDir, env.v2());
+ // check that the installed program has execute permissions, even though the one added to the plugin didn't
+ Path installedPlatformBinDir = env.v2().pluginsFile().resolve("fake").resolve("platform").resolve("linux-x86_64").resolve("bin");
+ assertTrue(Files.isDirectory(installedPlatformBinDir));
+ Path installedProgramFile = installedPlatformBinDir.resolve("someprogram");
+ assertTrue(Files.isRegularFile(installedProgramFile));
+ Set<PosixFilePermission> installedPerms = Files.getPosixFilePermissions(installedProgramFile);
+ assertTrue(installedPerms.contains(PosixFilePermission.OWNER_EXECUTE));
+ assertTrue(installedPerms.contains(PosixFilePermission.GROUP_EXECUTE));
+ assertTrue(installedPerms.contains(PosixFilePermission.OTHERS_EXECUTE));
+ }
+
+ public void testConfig() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ Path configDir = pluginDir.resolve("config");
+ Files.createDirectory(configDir);
+ Files.createFile(configDir.resolve("custom.yaml"));
+ String pluginZip = createPlugin("fake", pluginDir);
+ installPlugin(pluginZip, env.v1());
+ assertPlugin("fake", pluginDir, env.v2());
+ }
+
+ public void testExistingConfig() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path envConfigDir = env.v2().configFile().resolve("fake");
+ Files.createDirectories(envConfigDir);
+ Files.write(envConfigDir.resolve("custom.yaml"), "existing config".getBytes(StandardCharsets.UTF_8));
+ Path pluginDir = createPluginDir(temp);
+ Path configDir = pluginDir.resolve("config");
+ Files.createDirectory(configDir);
+ Files.write(configDir.resolve("custom.yaml"), "new config".getBytes(StandardCharsets.UTF_8));
+ Files.createFile(configDir.resolve("other.yaml"));
+ String pluginZip = createPlugin("fake", pluginDir);
+ installPlugin(pluginZip, env.v1());
+ assertPlugin("fake", pluginDir, env.v2());
+ List<String> configLines = Files.readAllLines(envConfigDir.resolve("custom.yaml"), StandardCharsets.UTF_8);
+ assertEquals(1, configLines.size());
+ assertEquals("existing config", configLines.get(0));
+ assertTrue(Files.exists(envConfigDir.resolve("other.yaml")));
+ }
+
+ public void testConfigNotDir() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ Path configDir = pluginDir.resolve("config");
+ Files.createFile(configDir);
+ String pluginZip = createPlugin("fake", pluginDir);
+ UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip, env.v1()));
+ assertTrue(e.getMessage(), e.getMessage().contains("not a directory"));
+ assertInstallCleaned(env.v2());
+ }
+
+ public void testConfigContainsDir() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ Path dirInConfigDir = pluginDir.resolve("config").resolve("foo");
+ Files.createDirectories(dirInConfigDir);
+ Files.createFile(dirInConfigDir.resolve("myconfig.yml"));
+ String pluginZip = createPlugin("fake", pluginDir);
+ UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip, env.v1()));
+ assertTrue(e.getMessage(), e.getMessage().contains("Directories not allowed in config dir for plugin"));
+ assertInstallCleaned(env.v2());
+ }
+
+ public void testConfigConflict() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ Path configDir = pluginDir.resolve("config");
+ Files.createDirectory(configDir);
+ Files.createFile(configDir.resolve("myconfig.yml"));
+ String pluginZip = createPlugin("elasticsearch.yml", pluginDir);
+ FileAlreadyExistsException e = expectThrows(FileAlreadyExistsException.class, () -> installPlugin(pluginZip, env.v1()));
+ assertTrue(e.getMessage(), e.getMessage().contains(env.v2().configFile().resolve("elasticsearch.yml").toString()));
+ assertInstallCleaned(env.v2());
+ }
+
+ public void testMissingDescriptor() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ Files.createFile(pluginDir.resolve("fake.yml"));
+ String pluginZip = writeZip(pluginDir, "elasticsearch");
+ NoSuchFileException e = expectThrows(NoSuchFileException.class, () -> installPlugin(pluginZip, env.v1()));
+ assertTrue(e.getMessage(), e.getMessage().contains("plugin-descriptor.properties"));
+ assertInstallCleaned(env.v2());
+ }
+
+ public void testMissingDirectory() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ Files.createFile(pluginDir.resolve(PluginInfo.ES_PLUGIN_PROPERTIES));
+ String pluginZip = writeZip(pluginDir, null);
+ UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip, env.v1()));
+ assertTrue(e.getMessage(), e.getMessage().contains("`elasticsearch` directory is missing in the plugin zip"));
+ assertInstallCleaned(env.v2());
+ }
+
+ public void testZipRelativeOutsideEntryName() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path zip = createTempDir().resolve("broken.zip");
+ try (ZipOutputStream stream = new ZipOutputStream(Files.newOutputStream(zip))) {
+ stream.putNextEntry(new ZipEntry("elasticsearch/../blah"));
+ }
+ String pluginZip = zip.toUri().toURL().toString();
+ UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip, env.v1()));
+ assertTrue(e.getMessage(), e.getMessage().contains("resolving outside of plugin directory"));
+ }
+
+ public void testOfficialPluginsHelpSorted() throws Exception {
+ MockTerminal terminal = new MockTerminal();
+ new InstallPluginCommand() {
+ @Override
+ protected boolean addShutdownHook() {
+ return false;
+ }
+ }.main(new String[] { "--help" }, terminal);
+ try (BufferedReader reader = new BufferedReader(new StringReader(terminal.getOutput()))) {
+ String line = reader.readLine();
+
+ // first find the beginning of our list of official plugins
+ while (line.endsWith("may be installed by name:") == false) {
+ line = reader.readLine();
+ }
+
+ // now check each line compares greater than the last, until we reach an empty line
+ String prev = reader.readLine();
+ line = reader.readLine();
+ while (line != null && line.trim().isEmpty() == false) {
+ assertTrue(prev + " < " + line, prev.compareTo(line) < 0);
+ prev = line;
+ line = reader.readLine();
+ }
+ }
+ }
+
+ public void testOfficialPluginsIncludesXpack() throws Exception {
+ MockTerminal terminal = new MockTerminal();
+ new InstallPluginCommand() {
+ @Override
+ protected boolean addShutdownHook() {
+ return false;
+ }
+ }.main(new String[] { "--help" }, terminal);
+ assertTrue(terminal.getOutput(), terminal.getOutput().contains("x-pack"));
+ }
+
+ public void testInstallMisspelledOfficialPlugins() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ UserException e = expectThrows(UserException.class, () -> installPlugin("xpack", env.v1()));
+ assertThat(e.getMessage(), containsString("Unknown plugin xpack, did you mean [x-pack]?"));
+
+ e = expectThrows(UserException.class, () -> installPlugin("analysis-smartnc", env.v1()));
+ assertThat(e.getMessage(), containsString("Unknown plugin analysis-smartnc, did you mean [analysis-smartcn]?"));
+
+ e = expectThrows(UserException.class, () -> installPlugin("repository", env.v1()));
+ assertThat(e.getMessage(), containsString("Unknown plugin repository, did you mean any of [repository-s3, repository-gcs]?"));
+
+ e = expectThrows(UserException.class, () -> installPlugin("unknown_plugin", env.v1()));
+ assertThat(e.getMessage(), containsString("Unknown plugin unknown_plugin"));
+ }
+
+ public void testBatchFlag() throws Exception {
+ MockTerminal terminal = new MockTerminal();
+ installPlugin(terminal, true);
+ assertThat(terminal.getOutput(), containsString("WARNING: plugin requires additional permissions"));
+ }
+
+ public void testQuietFlagDisabled() throws Exception {
+ MockTerminal terminal = new MockTerminal();
+ terminal.setVerbosity(randomFrom(Terminal.Verbosity.NORMAL, Terminal.Verbosity.VERBOSE));
+ installPlugin(terminal, false);
+ assertThat(terminal.getOutput(), containsString("100%"));
+ }
+
+ public void testQuietFlagEnabled() throws Exception {
+ MockTerminal terminal = new MockTerminal();
+ terminal.setVerbosity(Terminal.Verbosity.SILENT);
+ installPlugin(terminal, false);
+ assertThat(terminal.getOutput(), not(containsString("100%")));
+ }
+
+ public void testPluginAlreadyInstalled() throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ String pluginZip = createPlugin("fake", pluginDir);
+ installPlugin(pluginZip, env.v1());
+ final UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip, env.v1(), randomBoolean()));
+ assertThat(
+ e.getMessage(),
+ equalTo("plugin directory [" + env.v2().pluginsFile().resolve("fake") + "] already exists; " +
+ "if you need to update the plugin, uninstall it first using command 'remove fake'"));
+ }
+
+ private void installPlugin(MockTerminal terminal, boolean isBatch) throws Exception {
+ Tuple<Path, Environment> env = createEnv(fs, temp);
+ Path pluginDir = createPluginDir(temp);
+ // if batch is enabled, we also want to add a security policy
+ String pluginZip = createPlugin("fake", pluginDir, isBatch);
+
+ new InstallPluginCommand() {
+ @Override
+ void jarHellCheck(Path candidate, Path pluginsDir) throws Exception {
+ }
+ }.execute(terminal, pluginZip, isBatch, env.v2());
+ }
+
+ // TODO: test checksum (need maven/official below)
+ // TODO: test maven, official, and staging install...need tests with fixtures...
+}
diff --git a/distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/ListPluginsCommandTests.java b/distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/ListPluginsCommandTests.java
new file mode 100644
index 0000000000..7ebc2f0709
--- /dev/null
+++ b/distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/ListPluginsCommandTests.java
@@ -0,0 +1,240 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you 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 org.elasticsearch.plugins;
+
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.stream.Collectors;
+
+import org.apache.lucene.util.LuceneTestCase;
+import org.elasticsearch.Version;
+import org.elasticsearch.cli.ExitCodes;
+import org.elasticsearch.cli.MockTerminal;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.test.ESTestCase;
+import org.junit.Before;
+
+@LuceneTestCase.SuppressFileSystems("*")
+public class ListPluginsCommandTests extends ESTestCase {
+
+ private Path home;
+ private Environment env;
+
+ @Before
+ public void setUp() throws Exception {
+ super.setUp();
+ home = createTempDir();
+ Files.createDirectories(home.resolve("plugins"));
+ Settings settings = Settings.builder()
+ .put("path.home", home)
+ .build();
+ env = new Environment(settings);
+ }
+
+ static MockTerminal listPlugins(Path home) throws Exception {
+ return listPlugins(home, new String[0]);
+ }
+
+ static MockTerminal listPlugins(Path home, String[] args) throws Exception {
+ String[] argsAndHome = new String[args.length + 1];
+ System.arraycopy(args, 0, argsAndHome, 0, args.length);
+ argsAndHome[args.length] = "-Epath.home=" + home;
+ MockTerminal terminal = new MockTerminal();
+ int status = new ListPluginsCommand() {
+ @Override
+ protected boolean addShutdownHook() {
+ return false;
+ }
+ }.main(argsAndHome, terminal);
+ assertEquals(ExitCodes.OK, status);
+ return terminal;
+ }
+
+ private static String buildMultiline(String... args){
+ return Arrays.stream(args).collect(Collectors.joining("\n", "", "\n"));
+ }
+
+ private static void buildFakePlugin(
+ final Environment env,
+ final String description,
+ final String name,
+ final String classname) throws IOException {
+ buildFakePlugin(env, description, name, classname, false);
+ }
+
+ private static void buildFakePlugin(
+ final Environment env,
+ final String description,
+ final String name,
+ final String classname,
+ final boolean hasNativeController) throws IOException {
+ PluginTestUtil.writeProperties(
+ env.pluginsFile().resolve(name),
+ "description", description,
+ "name", name,
+ "version", "1.0",
+ "elasticsearch.version", Version.CURRENT.toString(),
+ "java.version", System.getProperty("java.specification.version"),
+ "classname", classname,
+ "has.native.controller", Boolean.toString(hasNativeController));
+ }
+
+ public void testPluginsDirMissing() throws Exception {
+ Files.delete(env.pluginsFile());
+ IOException e = expectThrows(IOException.class, () -> listPlugins(home));
+ assertEquals("Plugins directory missing: " + env.pluginsFile(), e.getMessage());
+ }
+
+ public void testNoPlugins() throws Exception {
+ MockTerminal terminal = listPlugins(home);
+ assertTrue(terminal.getOutput(), terminal.getOutput().isEmpty());
+ }
+
+ public void testOnePlugin() throws Exception {
+ buildFakePlugin(env, "fake desc", "fake", "org.fake");
+ MockTerminal terminal = listPlugins(home);
+ assertEquals(buildMultiline("fake"), terminal.getOutput());
+ }
+
+ public void testTwoPlugins() throws Exception {
+ buildFakePlugin(env, "fake desc", "fake1", "org.fake");
+ buildFakePlugin(env, "fake desc 2", "fake2", "org.fake");
+ MockTerminal terminal = listPlugins(home);
+ assertEquals(buildMultiline("fake1", "fake2"), terminal.getOutput());
+ }
+
+ public void testPluginWithVerbose() throws Exception {
+ buildFakePlugin(env, "fake desc", "fake_plugin", "org.fake");
+ String[] params = { "-v" };
+ MockTerminal terminal = listPlugins(home, params);
+ assertEquals(
+ buildMultiline(
+ "Plugins directory: " + env.pluginsFile(),
+ "fake_plugin",
+ "- Plugin information:",
+ "Name: fake_plugin",
+ "Description: fake desc",
+ "Version: 1.0",
+ "Native Controller: false",
+ " * Classname: org.fake"),
+ terminal.getOutput());
+ }
+
+ public void testPluginWithNativeController() throws Exception {
+ buildFakePlugin(env, "fake desc 1", "fake_plugin1", "org.fake", true);
+ String[] params = { "-v" };
+ MockTerminal terminal = listPlugins(home, params);
+ assertEquals(
+ buildMultiline(
+ "Plugins directory: " + env.pluginsFile(),
+ "fake_plugin1",
+ "- Plugin information:",
+ "Name: fake_plugin1",
+ "Description: fake desc 1",
+ "Version: 1.0",
+ "Native Controller: true",
+ " * Classname: org.fake"),
+ terminal.getOutput());
+ }
+
+ public void testPluginWithVerboseMultiplePlugins() throws Exception {
+ buildFakePlugin(env, "fake desc 1", "fake_plugin1", "org.fake");
+ buildFakePlugin(env, "fake desc 2", "fake_plugin2", "org.fake2");
+ String[] params = { "-v" };
+ MockTerminal terminal = listPlugins(home, params);
+ assertEquals(
+ buildMultiline(
+ "Plugins directory: " + env.pluginsFile(),
+ "fake_plugin1",
+ "- Plugin information:",
+ "Name: fake_plugin1",
+ "Description: fake desc 1",
+ "Version: 1.0",
+ "Native Controller: false",
+ " * Classname: org.fake",
+ "fake_plugin2",
+ "- Plugin information:",
+ "Name: fake_plugin2",
+ "Description: fake desc 2",
+ "Version: 1.0",
+ "Native Controller: false",
+ " * Classname: org.fake2"),
+ terminal.getOutput());
+ }
+
+ public void testPluginWithoutVerboseMultiplePlugins() throws Exception {
+ buildFakePlugin(env, "fake desc 1", "fake_plugin1", "org.fake");
+ buildFakePlugin(env, "fake desc 2", "fake_plugin2", "org.fake2");
+ MockTerminal terminal = listPlugins(home, new String[0]);
+ String output = terminal.getOutput();
+ assertEquals(buildMultiline("fake_plugin1", "fake_plugin2"), output);
+ }
+
+ public void testPluginWithoutDescriptorFile() throws Exception{
+ final Path pluginDir = env.pluginsFile().resolve("fake1");
+ Files.createDirectories(pluginDir);
+ NoSuchFileException e = expectThrows(NoSuchFileException.class, () -> listPlugins(home));
+ assertEquals(pluginDir.resolve(PluginInfo.ES_PLUGIN_PROPERTIES).toString(), e.getFile());
+ }
+
+ public void testPluginWithWrongDescriptorFile() throws Exception{
+ final Path pluginDir = env.pluginsFile().resolve("fake1");
+ PluginTestUtil.writeProperties(pluginDir, "description", "fake desc");
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> listPlugins(home));
+ final Path descriptorPath = pluginDir.resolve(PluginInfo.ES_PLUGIN_PROPERTIES);
+ assertEquals(
+ "property [name] is missing in [" + descriptorPath.toString() + "]",
+ e.getMessage());
+ }
+
+ public void testExistingIncompatiblePlugin() throws Exception {
+ PluginTestUtil.writeProperties(env.pluginsFile().resolve("fake_plugin1"),
+ "description", "fake desc 1",
+ "name", "fake_plugin1",
+ "version", "1.0",
+ "elasticsearch.version", Version.fromString("1.0.0").toString(),
+ "java.version", System.getProperty("java.specification.version"),
+ "classname", "org.fake1");
+ buildFakePlugin(env, "fake desc 2", "fake_plugin2", "org.fake2");
+
+ MockTerminal terminal = listPlugins(home);
+ final String message = String.format(Locale.ROOT,
+ "plugin [%s] is incompatible with version [%s]; was designed for version [%s]",
+ "fake_plugin1",
+ Version.CURRENT.toString(),
+ "1.0.0");
+ assertEquals(
+ "fake_plugin1\n" + "WARNING: " + message + "\n" + "fake_plugin2\n",
+ terminal.getOutput());
+
+ String[] params = {"-s"};
+ terminal = listPlugins(home, params);
+ assertEquals("fake_plugin1\nfake_plugin2\n", terminal.getOutput());
+ }
+
+}
diff --git a/distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/ProgressInputStreamTests.java b/distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/ProgressInputStreamTests.java
new file mode 100644
index 0000000000..813921963c
--- /dev/null
+++ b/distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/ProgressInputStreamTests.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you 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 org.elasticsearch.plugins;
+
+import org.elasticsearch.test.ESTestCase;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.hasItems;
+import static org.hamcrest.Matchers.hasSize;
+
+public class ProgressInputStreamTests extends ESTestCase {
+
+ private List<Integer> progresses = new ArrayList<>();
+
+ public void testThatProgressListenerIsCalled() throws Exception {
+ ProgressInputStream is = newProgressInputStream(0);
+ is.checkProgress(-1);
+
+ assertThat(progresses, hasSize(1));
+ assertThat(progresses, hasItems(100));
+ }
+
+ public void testThatProgressListenerIsCalledOnUnexpectedCompletion() throws Exception {
+ ProgressInputStream is = newProgressInputStream(2);
+ is.checkProgress(-1);
+ assertThat(progresses, hasItems(100));
+ }
+
+ public void testThatProgressListenerReturnsMaxValueOnWrongExpectedSize() throws Exception {
+ ProgressInputStream is = newProgressInputStream(2);
+
+ is.checkProgress(1);
+ assertThat(progresses, hasItems(50));
+
+ is.checkProgress(3);
+ assertThat(progresses, hasItems(50, 99));
+
+ is.checkProgress(-1);
+ assertThat(progresses, hasItems(50, 99, 100));
+ }
+
+ public void testOneByte() throws Exception {
+ ProgressInputStream is = newProgressInputStream(1);
+ is.checkProgress(1);
+ is.checkProgress(-1);
+
+ assertThat(progresses, hasItems(99, 100));
+
+ }
+
+ public void testOddBytes() throws Exception {
+ int odd = randomIntBetween(10, 100) * 2 + 1;
+ ProgressInputStream is = newProgressInputStream(odd);
+ for (int i = 0; i < odd; i++) {
+ is.checkProgress(1);
+ }
+ is.checkProgress(-1);
+
+ assertThat(progresses, hasSize(Math.min(odd + 1, 100)));
+ assertThat(progresses, hasItem(100));
+ }
+
+ public void testEvenBytes() throws Exception {
+ int even = randomIntBetween(10, 100) * 2;
+ ProgressInputStream is = newProgressInputStream(even);
+
+ for (int i = 0; i < even; i++) {
+ is.checkProgress(1);
+ }
+ is.checkProgress(-1);
+
+ assertThat(progresses, hasSize(Math.min(even + 1, 100)));
+ assertThat(progresses, hasItem(100));
+ }
+
+ public void testOnProgressCannotBeCalledMoreThanOncePerPercent() throws Exception {
+ int count = randomIntBetween(150, 300);
+ ProgressInputStream is = newProgressInputStream(count);
+
+ for (int i = 0; i < count; i++) {
+ is.checkProgress(1);
+ }
+ is.checkProgress(-1);
+
+ assertThat(progresses, hasSize(100));
+ }
+
+ private ProgressInputStream newProgressInputStream(int expectedSize) {
+ return new ProgressInputStream(null, expectedSize) {
+ @Override
+ public void onProgress(int percent) {
+ progresses.add(percent);
+ }
+ };
+ }
+} \ No newline at end of file
diff --git a/distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/RemovePluginCommandTests.java b/distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/RemovePluginCommandTests.java
new file mode 100644
index 0000000000..a42e66fe87
--- /dev/null
+++ b/distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/RemovePluginCommandTests.java
@@ -0,0 +1,167 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you 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 org.elasticsearch.plugins;
+
+import org.apache.lucene.util.LuceneTestCase;
+import org.elasticsearch.cli.ExitCodes;
+import org.elasticsearch.cli.MockTerminal;
+import org.elasticsearch.cli.UserException;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.test.ESTestCase;
+import org.junit.Before;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.StringReader;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.not;
+
+@LuceneTestCase.SuppressFileSystems("*")
+public class RemovePluginCommandTests extends ESTestCase {
+
+ private Path home;
+ private Environment env;
+
+ @Override
+ @Before
+ public void setUp() throws Exception {
+ super.setUp();
+ home = createTempDir();
+ Files.createDirectories(home.resolve("bin"));
+ Files.createFile(home.resolve("bin").resolve("elasticsearch"));
+ Files.createDirectories(home.resolve("plugins"));
+ Settings settings = Settings.builder()
+ .put("path.home", home)
+ .build();
+ env = new Environment(settings);
+ }
+
+ static MockTerminal removePlugin(String name, Path home) throws Exception {
+ Environment env = new Environment(Settings.builder().put("path.home", home).build());
+ MockTerminal terminal = new MockTerminal();
+ new RemovePluginCommand().execute(terminal, name, env);
+ return terminal;
+ }
+
+ static void assertRemoveCleaned(Environment env) throws IOException {
+ try (DirectoryStream<Path> stream = Files.newDirectoryStream(env.pluginsFile())) {
+ for (Path file : stream) {
+ if (file.getFileName().toString().startsWith(".removing")) {
+ fail("Removal dir still exists, " + file);
+ }
+ }
+ }
+ }
+
+ public void testMissing() throws Exception {
+ UserException e = expectThrows(UserException.class, () -> removePlugin("dne", home));
+ assertTrue(e.getMessage(), e.getMessage().contains("plugin [dne] not found"));
+ assertRemoveCleaned(env);
+ }
+
+ public void testBasic() throws Exception {
+ Files.createDirectory(env.pluginsFile().resolve("fake"));
+ Files.createFile(env.pluginsFile().resolve("fake").resolve("plugin.jar"));
+ Files.createDirectory(env.pluginsFile().resolve("fake").resolve("subdir"));
+ Files.createDirectory(env.pluginsFile().resolve("other"));
+ removePlugin("fake", home);
+ assertFalse(Files.exists(env.pluginsFile().resolve("fake")));
+ assertTrue(Files.exists(env.pluginsFile().resolve("other")));
+ assertRemoveCleaned(env);
+ }
+
+ public void testBin() throws Exception {
+ Files.createDirectories(env.pluginsFile().resolve("fake"));
+ Path binDir = env.binFile().resolve("fake");
+ Files.createDirectories(binDir);
+ Files.createFile(binDir.resolve("somescript"));
+ removePlugin("fake", home);
+ assertFalse(Files.exists(env.pluginsFile().resolve("fake")));
+ assertTrue(Files.exists(env.binFile().resolve("elasticsearch")));
+ assertFalse(Files.exists(binDir));
+ assertRemoveCleaned(env);
+ }
+
+ public void testBinNotDir() throws Exception {
+ Files.createDirectories(env.pluginsFile().resolve("elasticsearch"));
+ UserException e = expectThrows(UserException.class, () -> removePlugin("elasticsearch", home));
+ assertTrue(e.getMessage(), e.getMessage().contains("not a directory"));
+ assertTrue(Files.exists(env.pluginsFile().resolve("elasticsearch"))); // did not remove
+ assertTrue(Files.exists(env.binFile().resolve("elasticsearch")));
+ assertRemoveCleaned(env);
+ }
+
+ public void testConfigDirPreserved() throws Exception {
+ Files.createDirectories(env.pluginsFile().resolve("fake"));
+ final Path configDir = env.configFile().resolve("fake");
+ Files.createDirectories(configDir);
+ Files.createFile(configDir.resolve("fake.yml"));
+ final MockTerminal terminal = removePlugin("fake", home);
+ assertTrue(Files.exists(env.configFile().resolve("fake")));
+ assertThat(terminal.getOutput(), containsString(expectedConfigDirPreservedMessage(configDir)));
+ assertRemoveCleaned(env);
+ }
+
+ public void testNoConfigDirPreserved() throws Exception {
+ Files.createDirectories(env.pluginsFile().resolve("fake"));
+ final Path configDir = env.configFile().resolve("fake");
+ final MockTerminal terminal = removePlugin("fake", home);
+ assertThat(terminal.getOutput(), not(containsString(expectedConfigDirPreservedMessage(configDir))));
+ }
+
+ public void testRemoveUninstalledPluginErrors() throws Exception {
+ UserException e = expectThrows(UserException.class, () -> removePlugin("fake", home));
+ assertEquals(ExitCodes.CONFIG, e.exitCode);
+ assertEquals("plugin [fake] not found; run 'elasticsearch-plugin list' to get list of installed plugins", e.getMessage());
+
+ MockTerminal terminal = new MockTerminal();
+ new RemovePluginCommand() {
+ @Override
+ protected boolean addShutdownHook() {
+ return false;
+ }
+ }.main(new String[] { "-Epath.home=" + home, "fake" }, terminal);
+ try (BufferedReader reader = new BufferedReader(new StringReader(terminal.getOutput()))) {
+ assertEquals("-> removing [fake]...", reader.readLine());
+ assertEquals("ERROR: plugin [fake] not found; run 'elasticsearch-plugin list' to get list of installed plugins",
+ reader.readLine());
+ assertNull(reader.readLine());
+ }
+ }
+
+ public void testMissingPluginName() throws Exception {
+ UserException e = expectThrows(UserException.class, () -> removePlugin(null, home));
+ assertEquals(ExitCodes.USAGE, e.exitCode);
+ assertEquals("plugin name is required", e.getMessage());
+ }
+
+ private String expectedConfigDirPreservedMessage(final Path configDir) {
+ return "-> preserving plugin config files [" + configDir + "] in case of upgrade; delete manually if not needed";
+ }
+
+}
+