Browse Source

Introduce declarative plugin management (#77544)

Closes #70219.

Introduce a declarative way for the Elasticsearch server to manage plugins,
which reads the `elasticsearch-plugins.yml` file and works which out
plugins need to be added and / or removed to match the configuration. Also
make it possible to configure a proxy in the config file, instead of
through the environment.

Most of the work of adding and removing is still done in the
`InstallPluginAction` and `RemovePluginAction` classes, so the
behaviour should be the same as with the `install` and `remove`
commands. However, these commands will now abort if the above config
file exists. The intent is to make it harder for the configuration
to drift.

This new method only applies to `docker` distribution types at the
moment.

Since this syncing mechanism declarative, rather than imperative,
the Cloud-specific plugin wrapper script is no longer required.
Instead, an environment variable informs `InstallPluginAction` to
install plugins from an archive directory instead of downloading
them, where possible.
Rory Hunter 3 years ago
parent
commit
3018e52335
48 changed files with 2070 additions and 380 deletions
  1. 2 1
      build-tools-internal/src/main/groovy/elasticsearch.local-distribution.gradle
  2. 4 3
      build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/test/DistroTestPlugin.java
  3. 1 1
      build-tools-internal/src/main/resources/checkstyle_ide_fragment.xml
  4. 1 1
      distribution/build.gradle
  5. 0 6
      distribution/docker/build.gradle
  6. 7 4
      distribution/docker/src/docker/Dockerfile
  7. 1 0
      distribution/docker/src/docker/Dockerfile.cloud-ess
  8. 0 34
      distribution/docker/src/docker/cloud/plugin-wrapper.sh
  9. 1 0
      distribution/packages/build.gradle
  10. 27 0
      distribution/src/config/elasticsearch-plugins.example.yml
  11. 0 4
      distribution/tools/plugin-cli/build.gradle
  12. 0 1
      distribution/tools/plugin-cli/licenses/jackson-annotations-2.10.4.jar.sha1
  13. 0 8
      distribution/tools/plugin-cli/licenses/jackson-annotations-LICENSE
  14. 0 20
      distribution/tools/plugin-cli/licenses/jackson-annotations-NOTICE.txt
  15. 0 1
      distribution/tools/plugin-cli/licenses/jackson-databind-2.10.4.jar.sha1
  16. 0 8
      distribution/tools/plugin-cli/licenses/jackson-databind-LICENSE
  17. 0 20
      distribution/tools/plugin-cli/licenses/jackson-databind-NOTICE.txt
  18. 92 48
      distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/InstallPluginAction.java
  19. 7 3
      distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/InstallPluginCommand.java
  20. 6 2
      distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/ListPluginsCommand.java
  21. 26 25
      distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/PluginDescriptor.java
  22. 37 18
      distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/PluginSecurity.java
  23. 23 0
      distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/PluginSyncException.java
  24. 194 0
      distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/PluginsConfig.java
  25. 2 2
      distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/ProgressInputStream.java
  26. 59 0
      distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/ProxyUtils.java
  27. 8 4
      distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/RemovePluginAction.java
  28. 2 0
      distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/RemovePluginCommand.java
  29. 312 0
      distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/SyncPluginsAction.java
  30. 104 122
      distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/cli/InstallPluginActionTests.java
  31. 36 0
      distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/cli/MockInstallPluginCommand.java
  32. 26 0
      distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/cli/MockRemovePluginCommand.java
  33. 57 0
      distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/cli/ProxyMatcher.java
  34. 55 0
      distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/cli/ProxyUtilsTests.java
  35. 4 19
      distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/cli/RemovePluginActionTests.java
  36. 296 0
      distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/cli/SyncPluginsActionTests.java
  37. 12 2
      libs/cli/src/main/java/org/elasticsearch/cli/Terminal.java
  38. 183 10
      qa/os/src/test/java/org/elasticsearch/packaging/test/DockerTests.java
  39. 22 0
      qa/os/src/test/java/org/elasticsearch/packaging/test/HttpClientThreadsFilter.java
  40. 49 8
      qa/os/src/test/java/org/elasticsearch/packaging/test/PluginCliTests.java
  41. 1 3
      qa/os/src/test/java/org/elasticsearch/packaging/util/docker/Docker.java
  42. 5 0
      qa/os/src/test/java/org/elasticsearch/packaging/util/docker/DockerRun.java
  43. 201 0
      qa/os/src/test/java/org/elasticsearch/packaging/util/docker/MockServer.java
  44. 16 0
      server/src/main/java/org/elasticsearch/bootstrap/Bootstrap.java
  45. 94 0
      server/src/main/java/org/elasticsearch/bootstrap/plugins/LoggerTerminal.java
  46. 75 0
      server/src/main/java/org/elasticsearch/bootstrap/plugins/PluginsManager.java
  47. 5 2
      server/src/main/java/org/elasticsearch/plugins/PluginsService.java
  48. 17 0
      server/src/main/java/org/elasticsearch/plugins/PluginsSynchronizer.java

+ 2 - 1
build-tools-internal/src/main/groovy/elasticsearch.local-distribution.gradle

@@ -13,6 +13,7 @@
  * build/distributions/local
  * */
 import org.elasticsearch.gradle.Architecture
+import org.elasticsearch.gradle.VersionProperties
 
 // gradle has an open issue of failing applying plugins in
 // precompiled script plugins (see https://github.com/gradle/gradle/issues/17004)
@@ -29,6 +30,6 @@ tasks.register('localDistro', Sync) {
   from(elasticsearch_distributions.local)
   into("build/distribution/local")
   doLast {
-    logger.lifecycle("Elasticsearch distribution installed to ${destinationDir}.")
+    logger.lifecycle("Elasticsearch distribution installed to ${destinationDir}/elasticsearch-${VersionProperties.elasticsearch}")
   }
 }

+ 4 - 3
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/test/DistroTestPlugin.java

@@ -102,7 +102,7 @@ public class DistroTestPlugin implements Plugin<Project> {
         Map<String, TaskProvider<?>> versionTasks = versionTasks(project, "destructiveDistroUpgradeTest");
         TaskProvider<Task> destructiveDistroTest = project.getTasks().register("destructiveDistroTest");
 
-        // Configuration examplePlugin = configureExamplePlugin(project);
+        Configuration examplePlugin = configureExamplePlugin(project);
 
         List<TaskProvider<Test>> windowsTestTasks = new ArrayList<>();
         Map<ElasticsearchDistributionType, List<TaskProvider<Test>>> linuxTestTasks = new HashMap<>();
@@ -114,11 +114,12 @@ public class DistroTestPlugin implements Plugin<Project> {
             TaskProvider<?> depsTask = project.getTasks().register(taskname + "#deps");
             // explicitly depend on the archive not on the implicit extracted distribution
             depsTask.configure(t -> t.dependsOn(distribution.getArchiveDependencies()));
+            depsTask.configure(t -> t.dependsOn(examplePlugin.getDependencies()));
             depsTasks.put(taskname, depsTask);
             TaskProvider<Test> destructiveTask = configureTestTask(project, taskname, distribution, t -> {
                 t.onlyIf(t2 -> distribution.isDocker() == false || dockerSupport.get().getDockerAvailability().isAvailable);
                 addDistributionSysprop(t, DISTRIBUTION_SYSPROP, distribution::getFilepath);
-                // addDistributionSysprop(t, EXAMPLE_PLUGIN_SYSPROP, () -> examplePlugin.getSingleFile().toString());
+                addDistributionSysprop(t, EXAMPLE_PLUGIN_SYSPROP, () -> examplePlugin.getSingleFile().toString());
                 t.exclude("**/PackageUpgradeTests.class");
             }, depsTask);
 
@@ -314,7 +315,7 @@ public class DistroTestPlugin implements Plugin<Project> {
         Configuration examplePlugin = project.getConfigurations().create(EXAMPLE_PLUGIN_CONFIGURATION);
         examplePlugin.getAttributes().attribute(ArtifactAttributes.ARTIFACT_FORMAT, ArtifactTypeDefinition.ZIP_TYPE);
         DependencyHandler deps = project.getDependencies();
-        deps.add(EXAMPLE_PLUGIN_CONFIGURATION, deps.create("org.elasticsearch.examples:custom-settings:1.0.0-SNAPSHOT"));
+        deps.add(EXAMPLE_PLUGIN_CONFIGURATION, deps.project(Map.of("path", ":plugins:analysis-icu", "configuration", "zip")));
         return examplePlugin;
     }
 

+ 1 - 1
build-tools-internal/src/main/resources/checkstyle_ide_fragment.xml

@@ -34,7 +34,7 @@
         <message key="javadoc.missing" value="Types should explain their purpose" />
     </module>
 
-    <!-- Public methods must have JavaDoc -->
+    <!-- Check the Javadoc for a method e.g that it has the correct parameters, return type etc -->
     <module name="JavadocMethod">
         <property name="severity" value="warning"/>
         <property name="accessModifiers" value="public"/>

+ 1 - 1
distribution/build.gradle

@@ -296,7 +296,7 @@ configure(subprojects.findAll { ['archives', 'packages'].contains(it.name) }) {
      *****************************************************************************/
     libFiles =
       copySpec {
-        // delay by using closures, since they have not yet been configured, so no jar task exists yet
+        // Delay by using closures, since they have not yet been configured, so no jar task exists yet.
         from(configurations.libs)
         into('tools/geoip-cli') {
           from(configurations.libsGeoIpCli)

+ 0 - 6
distribution/docker/build.gradle

@@ -265,12 +265,6 @@ void addBuildDockerContextTask(Architecture architecture, DockerBase base) {
         }
         // For some reason, the artifact name can differ depending on what repository we used.
         rename ~/((?:file|metric)beat)-.*\.tar\.gz$/, "\$1-${VersionProperties.elasticsearch}.tar.gz"
-
-        into('bin') {
-          from(project.projectDir.toPath().resolve('src/docker/cloud')) {
-            expand([ version: VersionProperties.elasticsearch ])
-          }
-        }
       }
 
       onlyIf { Architecture.current() == architecture }

+ 7 - 4
distribution/docker/src/docker/Dockerfile

@@ -110,12 +110,17 @@ RUN sed -i -e 's/ES_DISTRIBUTION_TYPE=tar/ES_DISTRIBUTION_TYPE=docker/' bin/elas
     find config -type f -exec chmod 0664 {} +
 
 <% if (docker_base == "cloud") { %>
-# Preinstall common plugins
+# Preinstall common plugins. Note that these are installed as root, meaning the `elasticsearch` user cannot delete them.
 COPY repository-s3-${version}.zip repository-gcs-${version}.zip repository-azure-${version}.zip /tmp/
-RUN bin/elasticsearch-plugin install --batch \\
+RUN bin/elasticsearch-plugin install --batch --verbose \\
     file:/tmp/repository-s3-${version}.zip \\
     file:/tmp/repository-gcs-${version}.zip \\
     file:/tmp/repository-azure-${version}.zip
+# Generate a replacement example plugins config that reflects what is actually installed
+RUN echo "plugins:" > config/elasticsearch-plugins.example.yml && \\
+    echo "  - id: repository-azure" >> config/elasticsearch-plugins.example.yml && \\
+    echo "  - id: repository-gcs"   >> config/elasticsearch-plugins.example.yml && \\
+    echo "  - id: repository-s3"    >> config/elasticsearch-plugins.example.yml
 
 <% /*  I tried to use `ADD` here, but I couldn't force it to do what I wanted */ %>
 COPY filebeat-${version}.tar.gz metricbeat-${version}.tar.gz /tmp/
@@ -125,8 +130,6 @@ RUN mkdir -p /opt/filebeat /opt/metricbeat && \\
 
 # Add plugins infrastructure
 RUN mkdir -p /opt/plugins/archive
-COPY bin/plugin-wrapper.sh /opt/plugins
-# These are the correct permissions for both the directories and the script
 RUN chmod -R 0555 /opt/plugins
 <% } %>
 

+ 1 - 0
distribution/docker/src/docker/Dockerfile.cloud-ess

@@ -10,3 +10,4 @@ RUN chmod 0444 /opt/plugins/archive/*
 FROM ${base_image}
 
 COPY --from=builder /opt/plugins /opt/plugins
+ENV ES_PLUGIN_ARCHIVE_DIR /opt/plugins/archive

+ 0 - 34
distribution/docker/src/docker/cloud/plugin-wrapper.sh

@@ -1,34 +0,0 @@
-#!/bin/bash
-#
-# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
-# or more contributor license agreements. Licensed under the Elastic License
-# 2.0 and the Server Side Public License, v 1; you may not use this file except
-# in compliance with, at your election, the Elastic License 2.0 or the Server
-# Side Public License, v 1.
-#
-
-<% /* Populated by Gradle */ %>
-VERSION="$version"
-
-plugin_name_is_next=0
-
-declare -a args_array
-
-while test \$# -gt 0; do
-  opt="\$1"
-  shift
-
-  if [[ \$plugin_name_is_next -eq 1 ]]; then
-    if [[ -f "/opt/plugins/archive/\$opt-\${VERSION}.zip" ]]; then
-      opt="file:/opt/plugins/archive/\$opt-\${VERSION}.zip"
-    fi
-  elif [[ "\$opt" == "install" ]]; then
-    plugin_name_is_next=1
-  fi
-
-  args_array+=("\$opt")
-done
-
-set -- "\$@" "\${args_array[@]}"
-
-exec /usr/share/elasticsearch/bin/elasticsearch-plugin "\$@"

+ 1 - 0
distribution/packages/build.gradle

@@ -175,6 +175,7 @@ def commonPackageConfig(String type, String architecture) {
 
     // ========= config files =========
     configurationFile '/etc/elasticsearch/elasticsearch.yml'
+    configurationFile '/etc/elasticsearch/elasticsearch-plugins.example.yml'
     configurationFile '/etc/elasticsearch/jvm.options'
     configurationFile '/etc/elasticsearch/log4j2.properties'
     configurationFile '/etc/elasticsearch/role_mapping.yml'

+ 27 - 0
distribution/src/config/elasticsearch-plugins.example.yml

@@ -0,0 +1,27 @@
+# Rename this file to `elasticsearch-plugins.yml` to use it.
+#
+# All plugins must be listed here. If you add a plugin to this list and run
+# `elasticsearch-plugin sync`, that plugin will be installed. If you remove
+# a plugin from this list, that plugin will be removed when Elasticsearch
+# next starts.
+
+plugins:
+  # Each plugin must have an ID. Plugins with only an ID are official plugins and will be downloaded from Elastic.
+  - id: example-id
+
+  # Plugins can be specified by URL (it doesn't have to be HTTP, you could use e.g. `file:`)
+  - id: example-with-url
+    location: https://some.domain/path/example4.zip
+
+  # Or by maven coordinates:
+  - id: example-with-maven-url
+    location: org.elasticsearch.plugins:example-plugin:1.2.3
+
+  # A proxy can also be configured per-plugin, if necessary
+  - id: example-with-proxy
+    location: https://some.domain/path/example.zip
+    proxy: https://some.domain:1234
+
+# Configures a proxy for all network access. Remove this if you don't need
+# to use a proxy.
+proxy: https://some.domain:1234

+ 0 - 4
distribution/tools/plugin-cli/build.gradle

@@ -13,10 +13,6 @@ archivesBaseName = 'elasticsearch-plugin-cli'
 dependencies {
   compileOnly project(":server")
   compileOnly project(":libs:elasticsearch-cli")
-  api "com.fasterxml.jackson.core:jackson-annotations:${versions.jackson}"
-  api "com.fasterxml.jackson.core:jackson-core:${versions.jackson}"
-  api "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}"
-  api "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${versions.jackson}"
   api "org.bouncycastle:bcpg-fips:1.0.4"
   api "org.bouncycastle:bc-fips:1.0.2"
   testImplementation project(":test:framework")

+ 0 - 1
distribution/tools/plugin-cli/licenses/jackson-annotations-2.10.4.jar.sha1

@@ -1 +0,0 @@
-6ae6028aff033f194c9710ad87c224ccaadeed6c

+ 0 - 8
distribution/tools/plugin-cli/licenses/jackson-annotations-LICENSE

@@ -1,8 +0,0 @@
-This copy of Jackson JSON processor annotations is licensed under the
-Apache (Software) License, version 2.0 ("the License").
-See the License for details about distribution rights, and the
-specific rights regarding derivate works.
-
-You may obtain a copy of the License at:
-
-http://www.apache.org/licenses/LICENSE-2.0

+ 0 - 20
distribution/tools/plugin-cli/licenses/jackson-annotations-NOTICE.txt

@@ -1,20 +0,0 @@
-# Jackson JSON processor
-
-Jackson is a high-performance, Free/Open Source JSON processing library.
-It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has
-been in development since 2007.
-It is currently developed by a community of developers, as well as supported
-commercially by FasterXML.com.
-
-## Licensing
-
-Jackson core and extension components may be licensed under different licenses.
-To find the details that apply to this artifact see the accompanying LICENSE file.
-For more information, including possible other licensing options, contact
-FasterXML.com (http://fasterxml.com).
-
-## Credits
-
-A list of contributors may be found from CREDITS file, which is included
-in some artifacts (usually source distributions); but is always available
-from the source code management (SCM) system project uses.

+ 0 - 1
distribution/tools/plugin-cli/licenses/jackson-databind-2.10.4.jar.sha1

@@ -1 +0,0 @@
-76e9152e93d4cf052f93a64596f633ba5b1c8ed9

+ 0 - 8
distribution/tools/plugin-cli/licenses/jackson-databind-LICENSE

@@ -1,8 +0,0 @@
-This copy of Jackson JSON processor databind module is licensed under the
-Apache (Software) License, version 2.0 ("the License").
-See the License for details about distribution rights, and the
-specific rights regarding derivate works.
-
-You may obtain a copy of the License at:
-
-http://www.apache.org/licenses/LICENSE-2.0

+ 0 - 20
distribution/tools/plugin-cli/licenses/jackson-databind-NOTICE.txt

@@ -1,20 +0,0 @@
-# Jackson JSON processor
-
-Jackson is a high-performance, Free/Open Source JSON processing library.
-It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has
-been in development since 2007.
-It is currently developed by a community of developers, as well as supported
-commercially by FasterXML.com.
-
-## Licensing
-
-Jackson core and extension components may be licensed under different licenses.
-To find the details that apply to this artifact see the accompanying LICENSE file.
-For more information, including possible other licensing options, contact
-FasterXML.com (http://fasterxml.com).
-
-## Credits
-
-A list of contributors may be found from CREDITS file, which is included
-in some artifacts (usually source distributions); but is always available
-from the source code management (SCM) system project uses.

+ 92 - 48
distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/InstallPluginAction.java

@@ -32,6 +32,7 @@ import org.elasticsearch.cli.UserException;
 import org.elasticsearch.common.hash.MessageDigests;
 import org.elasticsearch.common.io.Streams;
 import org.elasticsearch.common.util.set.Sets;
+import org.elasticsearch.core.PathUtils;
 import org.elasticsearch.core.SuppressForbidden;
 import org.elasticsearch.core.Tuple;
 import org.elasticsearch.core.internal.io.IOUtils;
@@ -49,6 +50,7 @@ import java.io.InputStreamReader;
 import java.io.OutputStream;
 import java.io.UncheckedIOException;
 import java.net.HttpURLConnection;
+import java.net.Proxy;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URL;
@@ -114,7 +116,7 @@ import static org.elasticsearch.cli.Terminal.Verbosity.VERBOSE;
  * elasticsearch config directory, using the name of the plugin. If any files to be installed
  * already exist, they will be skipped.
  */
-class InstallPluginAction implements Closeable {
+public class InstallPluginAction implements Closeable {
 
     private static final String PROPERTY_STAGING_ID = "es.plugins.staging";
 
@@ -142,7 +144,7 @@ class InstallPluginAction implements Closeable {
     }
 
     /** The official plugins that can be installed simply by name. */
-    static final Set<String> OFFICIAL_PLUGINS;
+    public static final Set<String> OFFICIAL_PLUGINS;
     static {
         try (var stream = InstallPluginAction.class.getResourceAsStream("/plugins.txt")) {
             OFFICIAL_PLUGINS = Streams.readAllLines(stream).stream().map(String::trim).collect(Sets.toUnmodifiableSortedSet());
@@ -181,15 +183,20 @@ class InstallPluginAction implements Closeable {
     private final Terminal terminal;
     private Environment env;
     private boolean batch;
+    private Proxy proxy = null;
 
-    InstallPluginAction(Terminal terminal, Environment env, boolean batch) {
+    public InstallPluginAction(Terminal terminal, Environment env, boolean batch) {
         this.terminal = terminal;
         this.env = env;
         this.batch = batch;
     }
 
+    public void setProxy(Proxy proxy) {
+        this.proxy = proxy;
+    }
+
     // pkg private for testing
-    void execute(List<PluginDescriptor> plugins) throws Exception {
+    public void execute(List<PluginDescriptor> plugins) throws Exception {
         if (plugins.isEmpty()) {
             throw new UserException(ExitCodes.USAGE, "at least one plugin id is required");
         }
@@ -201,10 +208,12 @@ class InstallPluginAction implements Closeable {
             }
         }
 
+        final String logPrefix = terminal.isHeadless() ? "" : "-> ";
+
         final Map<String, List<Path>> deleteOnFailures = new LinkedHashMap<>();
         for (final PluginDescriptor plugin : plugins) {
             final String pluginId = plugin.getId();
-            terminal.println("-> Installing " + pluginId);
+            terminal.println(logPrefix + "Installing " + pluginId);
             try {
                 if ("x-pack".equals(pluginId)) {
                     handleInstallXPack(buildFlavor());
@@ -216,15 +225,15 @@ class InstallPluginAction implements Closeable {
                 final Path pluginZip = download(plugin, env.tmpFile());
                 final Path extractedZip = unzip(pluginZip, env.pluginsFile());
                 deleteOnFailure.add(extractedZip);
-                final PluginInfo pluginInfo = installPlugin(extractedZip, deleteOnFailure);
-                terminal.println("-> Installed " + pluginInfo.getName());
+                final PluginInfo pluginInfo = installPlugin(plugin, extractedZip, deleteOnFailure);
+                terminal.println(logPrefix + "Installed " + pluginInfo.getName());
                 // swap the entry by plugin id for one with the installed plugin name, it gives a cleaner error message for URL installs
                 deleteOnFailures.remove(pluginId);
                 deleteOnFailures.put(pluginInfo.getName(), deleteOnFailure);
             } catch (final Exception installProblem) {
-                terminal.println("-> Failed installing " + pluginId);
+                terminal.println(logPrefix + "Failed installing " + pluginId);
                 for (final Map.Entry<String, List<Path>> deleteOnFailureEntry : deleteOnFailures.entrySet()) {
-                    terminal.println("-> Rolling back " + deleteOnFailureEntry.getKey());
+                    terminal.println(logPrefix + "Rolling back " + deleteOnFailureEntry.getKey());
                     boolean success = false;
                     try {
                         IOUtils.rm(deleteOnFailureEntry.getValue().toArray(new Path[0]));
@@ -235,16 +244,18 @@ class InstallPluginAction implements Closeable {
                             exceptionWhileRemovingFiles
                         );
                         installProblem.addSuppressed(exception);
-                        terminal.println("-> Failed rolling back " + deleteOnFailureEntry.getKey());
+                        terminal.println(logPrefix + "Failed rolling back " + deleteOnFailureEntry.getKey());
                     }
                     if (success) {
-                        terminal.println("-> Rolled back " + deleteOnFailureEntry.getKey());
+                        terminal.println(logPrefix + "Rolled back " + deleteOnFailureEntry.getKey());
                     }
                 }
                 throw installProblem;
             }
         }
-        terminal.println("-> Please restart Elasticsearch to activate any plugins installed");
+        if (terminal.isHeadless() == false) {
+            terminal.println("-> Please restart Elasticsearch to activate any plugins installed");
+        }
     }
 
     Build.Flavor buildFlavor() {
@@ -271,24 +282,37 @@ class InstallPluginAction implements Closeable {
     private Path download(PluginDescriptor plugin, Path tmpDir) throws Exception {
         final String pluginId = plugin.getId();
 
-        if (OFFICIAL_PLUGINS.contains(pluginId)) {
+        final String logPrefix = terminal.isHeadless() ? "" : "-> ";
+
+        // See `InstallPluginCommand` it has to use a string argument for both the ID and the location
+        if (OFFICIAL_PLUGINS.contains(pluginId) && (plugin.getLocation() == null || plugin.getLocation().equals(pluginId))) {
+            final String pluginArchiveDir = System.getenv("ES_PLUGIN_ARCHIVE_DIR");
+            if (pluginArchiveDir != null && pluginArchiveDir.isEmpty() == false) {
+                final Path pluginPath = getPluginArchivePath(pluginId, pluginArchiveDir);
+                if (Files.exists(pluginPath)) {
+                    terminal.println(logPrefix + "Downloading " + pluginId + " from local archive: " + pluginArchiveDir);
+                    return downloadZip("file://" + pluginPath, tmpDir);
+                }
+                // else carry on to regular download
+            }
+
             final String url = getElasticUrl(getStagingHash(), Version.CURRENT, isSnapshot(), pluginId, Platforms.PLATFORM_NAME);
-            terminal.println("-> Downloading " + pluginId + " from elastic");
+            terminal.println(logPrefix + "Downloading " + pluginId + " from elastic");
             return downloadAndValidate(url, tmpDir, true);
         }
 
-        final String pluginUrl = plugin.getUrl();
+        final String pluginLocation = plugin.getLocation();
 
         // now try as maven coordinates, a valid URL would only have a colon and slash
-        String[] coordinates = pluginUrl.split(":");
-        if (coordinates.length == 3 && pluginUrl.contains("/") == false && pluginUrl.startsWith("file:") == false) {
-            String mavenUrl = getMavenUrl(coordinates, Platforms.PLATFORM_NAME);
-            terminal.println("-> Downloading " + pluginId + " from maven central");
+        String[] coordinates = pluginLocation.split(":");
+        if (coordinates.length == 3 && pluginLocation.contains("/") == false && pluginLocation.startsWith("file:") == false) {
+            String mavenUrl = getMavenUrl(coordinates);
+            terminal.println(logPrefix + "Downloading " + pluginId + " from maven central");
             return downloadAndValidate(mavenUrl, tmpDir, false);
         }
 
         // fall back to plain old URL
-        if (pluginUrl.contains(":") == false) {
+        if (pluginLocation.contains(":") == false) {
             // definitely not a valid url, so assume it is a plugin name
             List<String> pluginSuggestions = checkMisspelledPlugin(pluginId);
             String msg = "Unknown plugin " + pluginId;
@@ -297,8 +321,20 @@ class InstallPluginAction implements Closeable {
             }
             throw new UserException(ExitCodes.USAGE, msg);
         }
-        terminal.println("-> Downloading " + URLDecoder.decode(pluginUrl, StandardCharsets.UTF_8));
-        return downloadZip(pluginUrl, tmpDir);
+        terminal.println(logPrefix + "Downloading " + URLDecoder.decode(pluginLocation, StandardCharsets.UTF_8));
+        return downloadZip(pluginLocation, tmpDir);
+    }
+
+    @SuppressForbidden(reason = "Need to use PathUtils#get")
+    private Path getPluginArchivePath(String pluginId, String pluginArchiveDir) throws UserException {
+        final Path path = PathUtils.get(pluginArchiveDir);
+        if (Files.exists(path) == false) {
+            throw new UserException(ExitCodes.CONFIG, "Location in ES_PLUGIN_ARCHIVE_DIR does not exist");
+        }
+        if (Files.isDirectory(path) == false) {
+            throw new UserException(ExitCodes.CONFIG, "Location in ES_PLUGIN_ARCHIVE_DIR is not a directory");
+        }
+        return PathUtils.get(pluginArchiveDir, pluginId + "-" + Version.CURRENT + (isSnapshot() ? "-SNAPSHOT" : "") + ".zip");
     }
 
     // pkg private so tests can override
@@ -364,12 +400,12 @@ class InstallPluginAction implements Closeable {
     /**
      * Returns the url for an elasticsearch plugin in maven.
      */
-    private String getMavenUrl(String[] coordinates, String platform) throws IOException {
+    private String getMavenUrl(String[] coordinates) throws IOException {
         final String groupId = coordinates[0].replace(".", "/");
         final String artifactId = coordinates[1];
         final String version = coordinates[2];
         final String baseUrl = String.format(Locale.ROOT, "https://repo1.maven.org/maven2/%s/%s/%s", groupId, artifactId, version);
-        final String platformUrl = String.format(Locale.ROOT, "%s/%s-%s-%s.zip", baseUrl, artifactId, platform, version);
+        final String platformUrl = String.format(Locale.ROOT, "%s/%s-%s-%s.zip", baseUrl, artifactId, Platforms.PLATFORM_NAME, version);
         if (urlExists(platformUrl)) {
             return platformUrl;
         }
@@ -407,7 +443,7 @@ class InstallPluginAction implements Closeable {
             }
         }
         CollectionUtil.timSort(scoredKeys, (a, b) -> b.v1().compareTo(a.v1()));
-        return scoredKeys.stream().map((a) -> a.v2()).collect(Collectors.toList());
+        return scoredKeys.stream().map(Tuple::v2).collect(Collectors.toList());
     }
 
     /** Downloads a zip from the url, into a temp file under the given temp dir. */
@@ -417,10 +453,10 @@ class InstallPluginAction implements Closeable {
         terminal.println(VERBOSE, "Retrieving zip from " + urlString);
         URL url = new URL(urlString);
         Path zip = Files.createTempFile(tmpDir, null, ".zip");
-        URLConnection urlConnection = url.openConnection();
+        URLConnection urlConnection = this.proxy == null ? url.openConnection() : url.openConnection(this.proxy);
         urlConnection.addRequestProperty("User-Agent", "elasticsearch-plugin-installer");
         try (
-            InputStream in = batch
+            InputStream in = batch || terminal.isHeadless()
                 ? urlConnection.getInputStream()
                 : new TerminalProgressInputStream(urlConnection.getInputStream(), urlConnection.getContentLength(), terminal)
         ) {
@@ -443,10 +479,10 @@ class InstallPluginAction implements Closeable {
     /**
      * 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 static class TerminalProgressInputStream extends ProgressInputStream {
+        private static final int WIDTH = 50;
 
         private final Terminal terminal;
-        private int width = 50;
         private final boolean enabled;
 
         TerminalProgressInputStream(InputStream is, int expectedTotalSize, Terminal terminal) {
@@ -458,13 +494,13 @@ class InstallPluginAction implements Closeable {
         @Override
         public void onProgress(int percent) {
             if (enabled) {
-                int currentPosition = percent * width / 100;
+                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(String.join(" ", Collections.nCopies(WIDTH - currentPosition, "")));
                 sb.append("] %s   ");
                 if (percent == 100) {
                     sb.append("\n");
@@ -476,7 +512,7 @@ class InstallPluginAction implements Closeable {
 
     @SuppressForbidden(reason = "URL#openStream")
     private InputStream urlOpenStream(final URL url) throws IOException {
-        return url.openStream();
+        return this.proxy == null ? url.openStream() : url.openConnection(proxy).getInputStream();
     }
 
     /**
@@ -528,14 +564,10 @@ class InstallPluginAction implements Closeable {
              * matches, and that the file contains a single line. For SHA-512, we verify that the hash and the filename match, and that the
              * file contains a single line.
              */
+            final BufferedReader checksumReader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
             if (digestAlgo.equals("SHA-1")) {
-                final 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);
-                }
             } else {
-                final BufferedReader checksumReader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
                 final String checksumLine = checksumReader.readLine();
                 final String[] fields = checksumLine.split(" {2}");
                 if (officialPlugin && fields.length != 2 || officialPlugin == false && fields.length > 2) {
@@ -557,9 +589,9 @@ class InstallPluginAction implements Closeable {
                         throw new UserException(ExitCodes.IO_ERROR, message);
                     }
                 }
-                if (checksumReader.readLine() != null) {
-                    throw new UserException(ExitCodes.IO_ERROR, "Invalid checksum file at " + checksumUrl);
-                }
+            }
+            if (checksumReader.readLine() != null) {
+                throw new UserException(ExitCodes.IO_ERROR, "Invalid checksum file at " + checksumUrl);
             }
         }
 
@@ -598,7 +630,7 @@ class InstallPluginAction implements Closeable {
      * ".asc" to the URL. It is expected that the plugin is signed with the Elastic signing key with ID D27D666CD88E42B4.
      *
      * @param zip       the path to the downloaded plugin ZIP
-     * @param urlString the URL source of the downloade plugin ZIP
+     * @param urlString the URL source of the downloaded plugin ZIP
      * @throws IOException  if an I/O exception occurs reading from various input streams
      * @throws PGPException if the PGP implementation throws an internal exception during verification
      */
@@ -671,12 +703,14 @@ class InstallPluginAction implements Closeable {
     /**
      * Creates a URL and opens a connection.
      * <p>
-     * If the URL returns a 404, {@code null} is returned, otherwise the open URL opject is returned.
+     * If the URL returns a 404, {@code null} is returned, otherwise the open URL object is returned.
      */
     // pkg private for tests
     URL openUrl(String urlString) throws IOException {
         URL checksumUrl = new URL(urlString);
-        HttpURLConnection connection = (HttpURLConnection) checksumUrl.openConnection();
+        HttpURLConnection connection = this.proxy == null
+            ? (HttpURLConnection) checksumUrl.openConnection()
+            : (HttpURLConnection) checksumUrl.openConnection(this.proxy);
         if (connection.getResponseCode() == 404) {
             return null;
         }
@@ -832,7 +866,7 @@ class InstallPluginAction implements Closeable {
      * Installs the plugin from {@code tmpRoot} into the plugins dir.
      * If the plugin has a bin dir and/or a config dir, those are moved.
      */
-    private PluginInfo installPlugin(Path tmpRoot, List<Path> deleteOnFailure) throws Exception {
+    private PluginInfo installPlugin(PluginDescriptor descriptor, Path tmpRoot, List<Path> deleteOnFailure) throws Exception {
         final PluginInfo info = loadPluginInfo(tmpRoot);
         checkCanInstallationProceed(terminal, Build.CURRENT.flavor(), info);
         PluginPolicyInfo pluginPolicy = PolicyUtil.getPluginPolicyInfo(tmpRoot, env.tmpFile());
@@ -841,6 +875,16 @@ class InstallPluginAction implements Closeable {
             PluginSecurity.confirmPolicyExceptions(terminal, permissions, batch);
         }
 
+        // Validate that the downloaded plugin's ID matches what we expect from the descriptor. The
+        // exception is if we install a plugin via `InstallPluginCommand` by specifying a URL or
+        // Maven coordinates, because then we can't know in advance what the plugin ID ought to be.
+        if (descriptor.getId().contains(":") == false && descriptor.getId().equals(info.getName()) == false) {
+            throw new UserException(
+                PLUGIN_MALFORMED,
+                "Expected downloaded plugin to have ID [" + descriptor.getId() + "] but found [" + info.getName() + "]"
+            );
+        }
+
         final Path destination = env.pluginsFile().resolve(info.getName());
         deleteOnFailure.add(destination);
 
@@ -876,10 +920,10 @@ class InstallPluginAction implements Closeable {
 
     /**
      * Moves the plugin directory into its final destination.
-     **/
+     */
     private void movePlugin(Path tmpRoot, Path destination) throws IOException {
         Files.move(tmpRoot, destination, StandardCopyOption.ATOMIC_MOVE);
-        Files.walkFileTree(destination, new SimpleFileVisitor<Path>() {
+        Files.walkFileTree(destination, new SimpleFileVisitor<>() {
             @Override
             public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException {
                 final String parentDirName = file.getParent().getFileName().toString();
@@ -991,10 +1035,10 @@ class InstallPluginAction implements Closeable {
 
     @Override
     public void close() throws IOException {
-        IOUtils.rm(pathsToDeleteOnShutdown.toArray(new Path[pathsToDeleteOnShutdown.size()]));
+        IOUtils.rm(pathsToDeleteOnShutdown.toArray(new Path[0]));
     }
 
-    static void checkCanInstallationProceed(Terminal terminal, Build.Flavor flavor, PluginInfo info) throws Exception {
+    public static void checkCanInstallationProceed(Terminal terminal, Build.Flavor flavor, PluginInfo info) throws Exception {
         if (info.isLicensed() == false) {
             return;
         }

+ 7 - 3
distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/InstallPluginCommand.java

@@ -75,13 +75,17 @@ class InstallPluginCommand extends EnvironmentAwareCommand {
 
     @Override
     protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception {
+        SyncPluginsAction.ensureNoConfigFile(env);
+
         List<PluginDescriptor> plugins = arguments.values(options)
             .stream()
-            .map(id -> new PluginDescriptor(id, id))
+            // We only have one piece of data, which could be an ID or could be a location, so we use it for both
+            .map(idOrLocation -> new PluginDescriptor(idOrLocation, idOrLocation))
             .collect(Collectors.toList());
         final boolean isBatch = options.has(batchOption);
 
-        InstallPluginAction action = new InstallPluginAction(terminal, env, isBatch);
-        action.execute(plugins);
+        try (InstallPluginAction action = new InstallPluginAction(terminal, env, isBatch)) {
+            action.execute(plugins);
+        }
     }
 }

+ 6 - 2
distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/ListPluginsCommand.java

@@ -24,6 +24,8 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
+import static org.elasticsearch.plugins.cli.SyncPluginsAction.ELASTICSEARCH_PLUGINS_YML_CACHE;
+
 /**
  * A command for the plugin cli to list plugins installed in elasticsearch.
  */
@@ -42,8 +44,10 @@ class ListPluginsCommand extends EnvironmentAwareCommand {
         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);
+            for (Path path : paths) {
+                if (path.getFileName().toString().equals(ELASTICSEARCH_PLUGINS_YML_CACHE) == false) {
+                    plugins.add(path);
+                }
             }
         }
         Collections.sort(plugins);

+ 26 - 25
distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/PluginDescriptor.java

@@ -10,25 +10,29 @@ package org.elasticsearch.plugins.cli;
 
 import java.util.Objects;
 
+/**
+ * Models a single plugin that can be installed.
+ */
 public class PluginDescriptor {
     private String id;
-    private String url;
-    private String proxy;
+    private String location;
 
     public PluginDescriptor() {}
 
-    public PluginDescriptor(String id, String url, String proxy) {
-        this.id = id;
-        this.url = url;
-        this.proxy = proxy;
-    }
-
-    public PluginDescriptor(String id, String url) {
-        this(id, url, null);
+    /**
+     * Creates a new descriptor instance.
+     *
+     * @param id the name of the plugin. Cannot be null.
+     * @param location the location from which to fetch the plugin, e.g. a URL or Maven
+     *                 coordinates. Can be null for official plugins.
+     */
+    public PluginDescriptor(String id, String location) {
+        this.id = Objects.requireNonNull(id, "id cannot be null");
+        this.location = location;
     }
 
     public PluginDescriptor(String id) {
-        this(id, null, null);
+        this(id, null);
     }
 
     public String getId() {
@@ -39,20 +43,12 @@ public class PluginDescriptor {
         this.id = id;
     }
 
-    public String getUrl() {
-        return url;
-    }
-
-    public void setUrl(String url) {
-        this.url = url;
+    public String getLocation() {
+        return location;
     }
 
-    public String getProxy() {
-        return proxy;
-    }
-
-    public void setProxy(String proxy) {
-        this.proxy = proxy;
+    public void setLocation(String location) {
+        this.location = location;
     }
 
     @Override
@@ -60,11 +56,16 @@ public class PluginDescriptor {
         if (this == o) return true;
         if (o == null || getClass() != o.getClass()) return false;
         PluginDescriptor that = (PluginDescriptor) o;
-        return id.equals(that.id) && Objects.equals(url, that.url) && Objects.equals(proxy, that.proxy);
+        return id.equals(that.id) && Objects.equals(location, that.location);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(id, url, proxy);
+        return Objects.hash(id, location);
+    }
+
+    @Override
+    public String toString() {
+        return "PluginDescriptor{id='" + id + "', location='" + location + "'}";
     }
 }

+ 37 - 18
distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/PluginSecurity.java

@@ -27,6 +27,10 @@ import java.util.List;
 import java.util.Set;
 import java.util.stream.Collectors;
 
+/**
+ * Contains methods for displaying extended plugin permissions to the user, and confirming that
+ * plugin installation can proceed.
+ */
 public class PluginSecurity {
 
     /**
@@ -37,30 +41,45 @@ public class PluginSecurity {
         if (requested.isEmpty()) {
             terminal.println(Verbosity.VERBOSE, "plugin has a policy file with no additional permissions");
         } else {
-
             // sort permissions in a reasonable order
             Collections.sort(requested);
 
-            terminal.errorPrintln(Verbosity.NORMAL, "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@");
-            terminal.errorPrintln(Verbosity.NORMAL, "@     WARNING: plugin requires additional permissions     @");
-            terminal.errorPrintln(Verbosity.NORMAL, "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@");
-            // print all permissions:
-            for (String permission : requested) {
-                terminal.errorPrintln(Verbosity.NORMAL, "* " + permission);
+            if (terminal.isHeadless()) {
+                terminal.errorPrintln(
+                    "WARNING: plugin requires additional permissions: ["
+                        + requested.stream().map(each -> '\'' + each + '\'').collect(Collectors.joining(", "))
+                        + "]"
+                );
+                terminal.errorPrintln(
+                    "See https://docs.oracle.com/javase/8/docs/technotes/guides/security/permissions.html"
+                        + " for descriptions of what these permissions allow and the associated risks."
+                );
+            } else {
+                terminal.errorPrintln(Verbosity.NORMAL, "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@");
+                terminal.errorPrintln(Verbosity.NORMAL, "@     WARNING: plugin requires additional permissions     @");
+                terminal.errorPrintln(Verbosity.NORMAL, "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@");
+                // print all permissions:
+                for (String permission : requested) {
+                    terminal.errorPrintln(Verbosity.NORMAL, "* " + permission);
+                }
+                terminal.errorPrintln(
+                    Verbosity.NORMAL,
+                    "See https://docs.oracle.com/javase/8/docs/technotes/guides/security/permissions.html"
+                );
+                terminal.errorPrintln(Verbosity.NORMAL, "for descriptions of what these permissions allow and the associated risks.");
+
+                if (batch == false) {
+                    prompt(terminal);
+                }
             }
-            terminal.errorPrintln(Verbosity.NORMAL, "See http://docs.oracle.com/javase/8/docs/technotes/guides/security/permissions.html");
-            terminal.errorPrintln(Verbosity.NORMAL, "for descriptions of what these permissions allow and the associated risks.");
-            prompt(terminal, batch);
         }
     }
 
-    private static void prompt(final Terminal terminal, final boolean batch) throws UserException {
-        if (batch == false) {
-            terminal.println(Verbosity.NORMAL, "");
-            String text = terminal.readText("Continue with installation? [y/N]");
-            if (text.equalsIgnoreCase("y") == false) {
-                throw new UserException(ExitCodes.DATA_ERROR, "installation aborted by user");
-            }
+    private static void prompt(final Terminal terminal) throws UserException {
+        terminal.println(Verbosity.NORMAL, "");
+        String text = terminal.readText("Continue with installation? [y/N]");
+        if (text.equalsIgnoreCase("y") == false) {
+            throw new UserException(ExitCodes.DATA_ERROR, "installation aborted by user");
         }
     }
 
@@ -103,7 +122,7 @@ public class PluginSecurity {
     /**
      * Extract a unique set of permissions from the plugin's policy file. Each permission is formatted for output to users.
      */
-    static Set<String> getPermissionDescriptions(PluginPolicyInfo pluginPolicyInfo, Path tmpDir) throws IOException {
+    public static Set<String> getPermissionDescriptions(PluginPolicyInfo pluginPolicyInfo, Path tmpDir) throws IOException {
         Set<Permission> allPermissions = new HashSet<>(PolicyUtil.getPolicyPermissions(null, pluginPolicyInfo.policy, tmpDir));
         for (URL jar : pluginPolicyInfo.jars) {
             Set<Permission> jarPermissions = PolicyUtil.getPolicyPermissions(jar, pluginPolicyInfo.policy, tmpDir);

+ 23 - 0
distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/PluginSyncException.java

@@ -0,0 +1,23 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugins.cli;
+
+/**
+ * Thrown when a problem occurs synchronising plugins.
+ */
+class PluginSyncException extends Exception {
+
+    PluginSyncException(String message) {
+        super(message);
+    }
+
+    PluginSyncException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}

+ 194 - 0
distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/PluginsConfig.java

@@ -0,0 +1,194 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugins.cli;
+
+import org.elasticsearch.xcontent.ObjectParser;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xcontent.XContent;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xcontent.XContentParserConfiguration;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * This class models the contents of the {@code elasticsearch-plugins.yml} file. This file specifies all the plugins
+ * that ought to be installed in an Elasticsearch instance, and where to find them if they are not an official
+ * Elasticsearch plugin.
+ */
+public class PluginsConfig {
+    private List<PluginDescriptor> plugins;
+    private String proxy;
+
+    public PluginsConfig() {
+        plugins = List.of();
+        proxy = null;
+    }
+
+    public void setPlugins(List<PluginDescriptor> plugins) {
+        this.plugins = plugins == null ? List.of() : plugins;
+    }
+
+    public void setProxy(String proxy) {
+        this.proxy = proxy;
+    }
+
+    /**
+     * Validate this instance. For example:
+     * <ul>
+     *     <li>All {@link PluginDescriptor}s must have IDs</li>
+     *     <li>Any proxy must be well-formed.</li>
+     *     <li>Unofficial plugins must have locations</li>
+     * </ul>
+     *
+     * @param officialPlugins the plugins that can be installed by name only
+     * @throws PluginSyncException if validation problems are found
+     */
+    public void validate(Set<String> officialPlugins) throws PluginSyncException {
+        if (this.plugins.stream().anyMatch(each -> each == null || each.getId() == null || each.getId().isBlank())) {
+            throw new RuntimeException("Cannot have null or empty IDs in [elasticsearch-plugins.yml]");
+        }
+
+        final Set<String> uniquePluginIds = new HashSet<>();
+        for (final PluginDescriptor plugin : plugins) {
+            if (uniquePluginIds.add(plugin.getId()) == false) {
+                throw new PluginSyncException("Duplicate plugin ID [" + plugin.getId() + "] found in [elasticsearch-plugins.yml]");
+            }
+        }
+
+        for (PluginDescriptor plugin : this.plugins) {
+            if (officialPlugins.contains(plugin.getId()) == false && plugin.getLocation() == null) {
+                throw new PluginSyncException(
+                    "Must specify location for non-official plugin [" + plugin.getId() + "] in [elasticsearch-plugins.yml]"
+                );
+            }
+        }
+
+        if (this.proxy != null) {
+            final String[] parts = this.proxy.split(":");
+            if (parts.length != 2) {
+                throw new PluginSyncException("Malformed [proxy], expected [host:port] in [elasticsearch-plugins.yml]");
+            }
+
+            if (ProxyUtils.validateProxy(parts[0], parts[1]) == false) {
+                throw new PluginSyncException("Malformed [proxy], expected [host:port] in [elasticsearch-plugins.yml]");
+            }
+        }
+
+        for (PluginDescriptor p : plugins) {
+            if (p.getLocation() != null) {
+                if (p.getLocation().isBlank()) {
+                    throw new PluginSyncException("Empty location for plugin [" + p.getId() + "]");
+                }
+
+                try {
+                    // This also accepts Maven coordinates
+                    new URI(p.getLocation());
+                } catch (URISyntaxException e) {
+                    throw new PluginSyncException("Malformed location for plugin [" + p.getId() + "]");
+                }
+            }
+        }
+    }
+
+    public List<PluginDescriptor> getPlugins() {
+        return plugins;
+    }
+
+    public String getProxy() {
+        return proxy;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        PluginsConfig that = (PluginsConfig) o;
+        return plugins.equals(that.plugins) && Objects.equals(proxy, that.proxy);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(plugins, proxy);
+    }
+
+    @Override
+    public String toString() {
+        return "PluginsConfig{plugins=" + plugins + ", proxy='" + proxy + "'}";
+    }
+
+    /**
+     * Constructs a {@link PluginsConfig} instance from the config YAML file
+     *
+     * @param configPath the config file to load
+     * @param xContent the XContent type to expect when reading the file
+     * @return a validated config
+     */
+    static PluginsConfig parseConfig(Path configPath, XContent xContent) throws IOException {
+        // Normally a parser is declared and built statically in the class, but we'll only
+        // use this when starting up Elasticsearch, so there's no point keeping one around.
+
+        final ObjectParser<PluginDescriptor, Void> descriptorParser = new ObjectParser<>("descriptor parser", PluginDescriptor::new);
+        descriptorParser.declareString(PluginDescriptor::setId, new ParseField("id"));
+        descriptorParser.declareStringOrNull(PluginDescriptor::setLocation, new ParseField("location"));
+
+        final ObjectParser<PluginsConfig, Void> parser = new ObjectParser<>("plugins parser", PluginsConfig::new);
+        parser.declareStringOrNull(PluginsConfig::setProxy, new ParseField("proxy"));
+        parser.declareObjectArrayOrNull(PluginsConfig::setPlugins, descriptorParser, new ParseField("plugins"));
+
+        final XContentParser yamlXContentParser = xContent.createParser(
+            XContentParserConfiguration.EMPTY,
+            Files.newInputStream(configPath)
+        );
+
+        return parser.parse(yamlXContentParser, null);
+    }
+
+    /**
+     * Write a config file to disk
+     * @param xContent the format to use when writing the config
+     * @param config the config to write
+     * @param configPath the path to write to
+     * @throws IOException if anything breaks
+     */
+    static void writeConfig(XContent xContent, PluginsConfig config, Path configPath) throws IOException {
+        final OutputStream outputStream = Files.newOutputStream(configPath);
+        final XContentBuilder builder = new XContentBuilder(xContent, outputStream);
+
+        builder.startObject();
+        builder.startArray("plugins");
+        for (PluginDescriptor p : config.getPlugins()) {
+            builder.startObject();
+            {
+                builder.field("id", p.getId());
+                builder.field("location", p.getLocation());
+            }
+            builder.endObject();
+        }
+        builder.endArray();
+        builder.field("proxy", config.getProxy());
+        builder.endObject();
+
+        builder.close();
+        outputStream.close();
+    }
+}

+ 2 - 2
distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/ProgressInputStream.java

@@ -20,13 +20,13 @@ import java.io.InputStream;
  *
  * Only used by the InstallPluginCommand, thus package private here
  */
-abstract class ProgressInputStream extends FilterInputStream {
+public abstract class ProgressInputStream extends FilterInputStream {
 
     private final int expectedTotalSize;
     private int currentPercent;
     private int count = 0;
 
-    ProgressInputStream(InputStream is, int expectedTotalSize) {
+    public ProgressInputStream(InputStream is, int expectedTotalSize) {
         super(is);
         this.expectedTotalSize = expectedTotalSize;
         this.currentPercent = 0;

+ 59 - 0
distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/ProxyUtils.java

@@ -0,0 +1,59 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugins.cli;
+
+import org.elasticsearch.cli.ExitCodes;
+import org.elasticsearch.cli.SuppressForbidden;
+import org.elasticsearch.cli.UserException;
+import org.elasticsearch.common.Strings;
+
+import java.net.InetSocketAddress;
+import java.net.Proxy;
+
+/**
+ * Utilities for working with HTTP proxies.
+ */
+class ProxyUtils {
+    /**
+     * Constructs a proxy from the given string. If {@code null} is passed, then {@code null} will
+     * be returned, since that is not the same as {@link Proxy#NO_PROXY}.
+     *
+     * @param proxy the string to use, in the form "host:port"
+     * @return a proxy or null
+     */
+    @SuppressForbidden(reason = "Proxy constructor requires a SocketAddress")
+    static Proxy buildProxy(String proxy) throws UserException {
+        if (proxy == null) {
+            return null;
+        }
+
+        final String[] parts = proxy.split(":");
+        if (parts.length != 2) {
+            throw new UserException(ExitCodes.CONFIG, "Malformed [proxy], expected [host:port]");
+        }
+
+        if (validateProxy(parts[0], parts[1]) == false) {
+            throw new UserException(ExitCodes.CONFIG, "Malformed [proxy], expected [host:port]");
+        }
+
+        return new Proxy(Proxy.Type.HTTP, new InetSocketAddress(parts[0], Integer.parseUnsignedInt(parts[1])));
+    }
+
+    /**
+     * Check that the hostname is not empty, and that the port is numeric.
+     *
+     * @param hostname the hostname to check. Besides ensuring it is not null or empty, no further validation is
+     *                 performed.
+     * @param port the port to check. Must be composed solely of digits.
+     * @return whether the arguments describe a potentially valid proxy.
+     */
+    static boolean validateProxy(String hostname, String port) {
+        return Strings.isNullOrEmpty(hostname) == false && port != null && port.matches("^\\d+$") != false;
+    }
+}

+ 8 - 4
distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/RemovePluginAction.java

@@ -34,7 +34,7 @@ import static org.elasticsearch.cli.Terminal.Verbosity.VERBOSE;
 /**
  * An action for the plugin CLI to remove plugins from Elasticsearch.
  */
-class RemovePluginAction {
+public class RemovePluginAction {
 
     // exit codes for remove
     /** A plugin cannot be removed because it is extended by another plugin. */
@@ -42,7 +42,7 @@ class RemovePluginAction {
 
     private final Terminal terminal;
     private final Environment env;
-    private final boolean purge;
+    private boolean purge;
 
     /**
      * Creates a new action.
@@ -51,12 +51,16 @@ class RemovePluginAction {
      * @param env        the environment for the local node
      * @param purge      if true, plugin configuration files will be removed but otherwise preserved
      */
-    RemovePluginAction(Terminal terminal, Environment env, boolean purge) {
+    public RemovePluginAction(Terminal terminal, Environment env, boolean purge) {
         this.terminal = terminal;
         this.env = env;
         this.purge = purge;
     }
 
+    public void setPurge(boolean purge) {
+        this.purge = purge;
+    }
+
     /**
      * Remove the plugin specified by {@code pluginName}.
      *
@@ -66,7 +70,7 @@ class RemovePluginAction {
      * @throws UserException if plugin directory does not exist
      * @throws UserException if the plugin bin directory is not a directory
      */
-    void execute(List<PluginDescriptor> plugins) throws IOException, UserException {
+    public void execute(List<PluginDescriptor> plugins) throws IOException, UserException {
         if (plugins == null || plugins.isEmpty()) {
             throw new UserException(ExitCodes.USAGE, "At least one plugin ID is required");
         }

+ 2 - 0
distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/RemovePluginCommand.java

@@ -34,6 +34,8 @@ class RemovePluginCommand extends EnvironmentAwareCommand {
 
     @Override
     protected void execute(final Terminal terminal, final OptionSet options, final Environment env) throws Exception {
+        SyncPluginsAction.ensureNoConfigFile(env);
+
         final List<PluginDescriptor> plugins = arguments.values(options).stream().map(PluginDescriptor::new).collect(Collectors.toList());
 
         final RemovePluginAction action = new RemovePluginAction(terminal, env, options.has(purgeOption));

+ 312 - 0
distribution/tools/plugin-cli/src/main/java/org/elasticsearch/plugins/cli/SyncPluginsAction.java

@@ -0,0 +1,312 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugins.cli;
+
+import org.elasticsearch.Version;
+import org.elasticsearch.cli.ExitCodes;
+import org.elasticsearch.cli.Terminal;
+import org.elasticsearch.cli.UserException;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.plugins.PluginInfo;
+import org.elasticsearch.plugins.PluginsSynchronizer;
+import org.elasticsearch.xcontent.cbor.CborXContent;
+import org.elasticsearch.xcontent.yaml.YamlXContent;
+
+import java.io.IOException;
+import java.net.Proxy;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.BiConsumer;
+import java.util.stream.Collectors;
+
+/**
+ * This action compares the contents of a configuration files, {@code elasticsearch-plugins.yml}, with the currently
+ * installed plugins, and ensures that plugins are installed or removed accordingly.
+ * <p>
+ * This action cannot be called from the command line. It is used exclusively by Elasticsearch on startup, but only
+ * if the config file exists and the distribution type allows it.
+ */
+public class SyncPluginsAction implements PluginsSynchronizer {
+    public static final String ELASTICSEARCH_PLUGINS_YML = "elasticsearch-plugins.yml";
+    public static final String ELASTICSEARCH_PLUGINS_YML_CACHE = ".elasticsearch-plugins.yml.cache";
+
+    private final Terminal terminal;
+    private final Environment env;
+
+    public SyncPluginsAction(Terminal terminal, Environment env) {
+        this.terminal = terminal;
+        this.env = env;
+    }
+
+    /**
+     * Ensures that the plugin config file does <b>not</b> exist.
+     * @param env the environment to check
+     * @throws UserException if a plugins config file is found.
+     */
+    public static void ensureNoConfigFile(Environment env) throws UserException {
+        final Path pluginsConfig = env.configFile().resolve("elasticsearch-plugins.yml");
+        if (Files.exists(pluginsConfig)) {
+            throw new UserException(
+                ExitCodes.USAGE,
+                "Plugins config ["
+                    + pluginsConfig
+                    + "] exists, which is used by Elasticsearch on startup to ensure the correct plugins "
+                    + "are installed. Instead of using this tool, you need to update this config file and restart Elasticsearch."
+            );
+        }
+    }
+
+    /**
+     * Synchronises plugins from the config file to the plugins dir.
+     *
+     * @throws Exception if anything goes wrong
+     */
+    @Override
+    public void execute() throws Exception {
+        final Path configPath = this.env.configFile().resolve(ELASTICSEARCH_PLUGINS_YML);
+        final Path previousConfigPath = this.env.pluginsFile().resolve(ELASTICSEARCH_PLUGINS_YML_CACHE);
+
+        if (Files.exists(configPath) == false) {
+            // The `PluginsManager` will have checked that this file exists before invoking the action.
+            throw new PluginSyncException("Plugins config does not exist: " + configPath.toAbsolutePath());
+        }
+
+        if (Files.exists(env.pluginsFile()) == false) {
+            throw new PluginSyncException("Plugins directory missing: " + env.pluginsFile());
+        }
+
+        // Parse descriptor file
+        final PluginsConfig pluginsConfig = PluginsConfig.parseConfig(configPath, YamlXContent.yamlXContent);
+        pluginsConfig.validate(InstallPluginAction.OFFICIAL_PLUGINS);
+
+        // Parse cached descriptor file, if it exists
+        final Optional<PluginsConfig> cachedPluginsConfig = Files.exists(previousConfigPath)
+            ? Optional.of(PluginsConfig.parseConfig(previousConfigPath, CborXContent.cborXContent))
+            : Optional.empty();
+
+        final PluginChanges changes = getPluginChanges(pluginsConfig, cachedPluginsConfig);
+
+        if (changes.isEmpty()) {
+            terminal.println("No plugins to install, remove or upgrade");
+            return;
+        }
+
+        performSync(pluginsConfig, changes);
+
+        // 8. Cached the applied config so that we can diff it on the next run.
+        PluginsConfig.writeConfig(CborXContent.cborXContent, pluginsConfig, previousConfigPath);
+    }
+
+    // @VisibleForTesting
+    PluginChanges getPluginChanges(PluginsConfig pluginsConfig, Optional<PluginsConfig> cachedPluginsConfig) throws PluginSyncException {
+        final List<PluginInfo> existingPlugins = getExistingPlugins(this.env);
+
+        final List<PluginDescriptor> pluginsThatShouldExist = pluginsConfig.getPlugins();
+        final List<PluginDescriptor> pluginsThatActuallyExist = existingPlugins.stream()
+            .map(info -> new PluginDescriptor(info.getName()))
+            .collect(Collectors.toList());
+        final Set<String> existingPluginIds = pluginsThatActuallyExist.stream().map(PluginDescriptor::getId).collect(Collectors.toSet());
+
+        final List<PluginDescriptor> pluginsToInstall = difference(pluginsThatShouldExist, pluginsThatActuallyExist);
+        final List<PluginDescriptor> pluginsToRemove = difference(pluginsThatActuallyExist, pluginsThatShouldExist);
+
+        // Candidates for upgrade are any plugin that already exist and isn't about to be removed.
+        final List<PluginDescriptor> pluginsToMaybeUpgrade = difference(pluginsThatShouldExist, pluginsToRemove).stream()
+            .filter(each -> existingPluginIds.contains(each.getId()))
+            .collect(Collectors.toList());
+
+        final List<PluginDescriptor> pluginsToUpgrade = getPluginsToUpgrade(pluginsToMaybeUpgrade, cachedPluginsConfig, existingPlugins);
+
+        return new PluginChanges(pluginsToRemove, pluginsToInstall, pluginsToUpgrade);
+    }
+
+    private void performSync(PluginsConfig pluginsConfig, PluginChanges changes) throws Exception {
+        final Proxy proxy = ProxyUtils.buildProxy(pluginsConfig.getProxy());
+
+        final RemovePluginAction removePluginAction = new RemovePluginAction(terminal, env, true);
+        final InstallPluginAction installPluginAction = new InstallPluginAction(terminal, env, true);
+        installPluginAction.setProxy(proxy);
+
+        performSync(installPluginAction, removePluginAction, changes);
+    }
+
+    // @VisibleForTesting
+    void performSync(InstallPluginAction installAction, RemovePluginAction removeAction, PluginChanges changes) throws Exception {
+        logRequiredChanges(changes);
+
+        // Remove any plugins that are not in the config file
+        if (changes.remove.isEmpty() == false) {
+            removeAction.setPurge(true);
+            removeAction.execute(changes.remove);
+        }
+
+        // Add any plugins that are in the config file but missing from disk
+        if (changes.install.isEmpty() == false) {
+            installAction.execute(changes.install);
+        }
+
+        // Upgrade plugins
+        if (changes.upgrade.isEmpty() == false) {
+            removeAction.setPurge(false);
+            removeAction.execute(changes.upgrade);
+            installAction.execute(changes.upgrade);
+        }
+    }
+
+    private List<PluginDescriptor> getPluginsToUpgrade(
+        List<PluginDescriptor> pluginsToMaybeUpgrade,
+        Optional<PluginsConfig> cachedPluginsConfig,
+        List<PluginInfo> existingPlugins
+    ) {
+        final Map<String, String> cachedPluginIdToLocation = cachedPluginsConfig.map(
+            config -> config.getPlugins().stream().collect(Collectors.toMap(PluginDescriptor::getId, PluginDescriptor::getLocation))
+        ).orElse(Map.of());
+
+        return pluginsToMaybeUpgrade.stream().filter(eachPlugin -> {
+            final String eachPluginId = eachPlugin.getId();
+
+            // If a plugin's location has changed, reinstall
+            if (Objects.equals(eachPlugin.getLocation(), cachedPluginIdToLocation.get(eachPluginId)) == false) {
+                this.terminal.println(
+                    Terminal.Verbosity.VERBOSE,
+                    String.format(
+                        Locale.ROOT,
+                        "Location for plugin [%s] has changed from [%s] to [%s], reinstalling",
+                        eachPluginId,
+                        cachedPluginIdToLocation.get(eachPluginId),
+                        eachPlugin.getLocation()
+                    )
+                );
+                return true;
+            }
+
+            // Official plugins must be upgraded when an Elasticsearch node is upgraded.
+            if (InstallPluginAction.OFFICIAL_PLUGINS.contains(eachPluginId)) {
+                // Find the currently installed plugin and check whether the version is lower than
+                // the current node's version.
+                final PluginInfo info = existingPlugins.stream()
+                    .filter(each -> each.getName().equals(eachPluginId))
+                    .findFirst()
+                    .orElseThrow(() -> {
+                        // It should be literally impossible for us not to find a matching existing plugin. We derive
+                        // the list of existing plugin IDs from the list of installed plugins.
+                        throw new RuntimeException("Couldn't find a PluginInfo for [" + eachPluginId + "], which should be impossible");
+                    });
+
+                if (info.getElasticsearchVersion().before(Version.CURRENT)) {
+                    this.terminal.println(
+                        Terminal.Verbosity.VERBOSE,
+                        String.format(
+                            Locale.ROOT,
+                            "Official plugin [%s] is out-of-date (%s versus %s), upgrading",
+                            eachPluginId,
+                            info.getElasticsearchVersion(),
+                            Version.CURRENT
+                        )
+                    );
+                    return true;
+                }
+                return false;
+            }
+
+            // Else don't upgrade.
+            return false;
+        }).collect(Collectors.toList());
+    }
+
+    private List<PluginInfo> getExistingPlugins(Environment env) throws PluginSyncException {
+        final List<PluginInfo> plugins = new ArrayList<>();
+
+        try {
+            try (DirectoryStream<Path> paths = Files.newDirectoryStream(env.pluginsFile())) {
+                for (Path pluginPath : paths) {
+                    String filename = pluginPath.getFileName().toString();
+                    if (filename.startsWith(".")) {
+                        continue;
+                    }
+
+                    PluginInfo info = PluginInfo.readFromProperties(env.pluginsFile().resolve(pluginPath));
+                    plugins.add(info);
+
+                    // Check for a version mismatch, unless it's an official plugin since we can upgrade them.
+                    if (InstallPluginAction.OFFICIAL_PLUGINS.contains(info.getName())
+                        && info.getElasticsearchVersion().equals(Version.CURRENT) == false) {
+                        this.terminal.errorPrintln(
+                            String.format(
+                                Locale.ROOT,
+                                "WARNING: plugin [%s] was built for Elasticsearch version %s but version %s is required",
+                                info.getName(),
+                                info.getElasticsearchVersion(),
+                                Version.CURRENT
+                            )
+                        );
+                    }
+                }
+            }
+        } catch (IOException e) {
+            throw new PluginSyncException("Failed to list existing plugins", e);
+        }
+
+        return plugins;
+    }
+
+    /**
+     * Returns a list of all elements in {@code left} that are not present in {@code right}.
+     * <p>
+     * Comparisons are based solely using {@link PluginDescriptor#getId()}.
+     *
+     * @param left the items that may be retained
+     * @param right the items that may be removed
+     * @return a list of the remaining elements
+     */
+    private static List<PluginDescriptor> difference(List<PluginDescriptor> left, List<PluginDescriptor> right) {
+        return left.stream().filter(eachDescriptor -> {
+            final String id = eachDescriptor.getId();
+            return right.stream().anyMatch(p -> p.getId().equals(id)) == false;
+        }).collect(Collectors.toList());
+    }
+
+    private void logRequiredChanges(PluginChanges changes) {
+        final BiConsumer<String, List<PluginDescriptor>> printSummary = (action, plugins) -> {
+            if (plugins.isEmpty() == false) {
+                List<String> pluginIds = plugins.stream().map(PluginDescriptor::getId).collect(Collectors.toList());
+                this.terminal.errorPrintln(String.format(Locale.ROOT, "Plugins to be %s: %s", action, pluginIds));
+            }
+        };
+
+        printSummary.accept("removed", changes.remove);
+        printSummary.accept("installed", changes.install);
+        printSummary.accept("upgraded", changes.upgrade);
+    }
+
+    // @VisibleForTesting
+    static class PluginChanges {
+        final List<PluginDescriptor> remove;
+        final List<PluginDescriptor> install;
+        final List<PluginDescriptor> upgrade;
+
+        PluginChanges(List<PluginDescriptor> remove, List<PluginDescriptor> install, List<PluginDescriptor> upgrade) {
+            this.remove = Objects.requireNonNull(remove);
+            this.install = Objects.requireNonNull(install);
+            this.upgrade = Objects.requireNonNull(upgrade);
+        }
+
+        boolean isEmpty() {
+            return remove.isEmpty() && install.isEmpty() && upgrade.isEmpty();
+        }
+    }
+}

+ 104 - 122
distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/cli/InstallPluginActionTests.java

@@ -221,7 +221,7 @@ public class InstallPluginActionTests extends ESTestCase {
         Path zip = createTempDir().resolve(structure.getFileName() + ".zip");
         try (ZipOutputStream stream = new ZipOutputStream(Files.newOutputStream(zip))) {
             forEachFileRecursively(structure, (file, attrs) -> {
-                String target = (prefix == null ? "" : prefix + "/") + structure.relativize(file).toString();
+                String target = (prefix == null ? "" : prefix + "/") + structure.relativize(file);
                 stream.putNextEntry(new ZipEntry(target));
                 Files.copy(file, stream);
             });
@@ -415,20 +415,20 @@ public class InstallPluginActionTests extends ESTestCase {
     public void testDuplicateInstall() throws Exception {
         PluginDescriptor pluginZip = createPluginZip("fake", pluginDir);
         final UserException e = expectThrows(UserException.class, () -> installPlugins(List.of(pluginZip, pluginZip), env.v1()));
-        assertThat(e, hasToString(containsString("duplicate plugin id [" + pluginZip.getId() + "]")));
+        assertThat(e.getMessage(), equalTo("duplicate plugin id [" + pluginZip.getId() + "]"));
     }
 
     public void testTransaction() throws Exception {
         PluginDescriptor pluginZip = createPluginZip("fake", pluginDir);
         PluginDescriptor nonexistentPluginZip = new PluginDescriptor(
             pluginZip.getId() + "-does-not-exist",
-            pluginZip.getUrl() + "-does-not-exist"
+            pluginZip.getLocation() + "-does-not-exist"
         );
         final FileNotFoundException e = expectThrows(
             FileNotFoundException.class,
             () -> installPlugins(List.of(pluginZip, nonexistentPluginZip), env.v1())
         );
-        assertThat(e, hasToString(containsString("does-not-exist")));
+        assertThat(e.getMessage(), containsString("does-not-exist"));
         final Path fakeInstallPath = env.v2().pluginsFile().resolve("fake");
         // fake should have been removed when the file not found exception occurred
         assertFalse(Files.exists(fakeInstallPath));
@@ -445,13 +445,13 @@ public class InstallPluginActionTests extends ESTestCase {
             "found file [%s] from a failed attempt to remove the plugin [failed]; execute [elasticsearch-plugin remove failed]",
             removing
         );
-        assertThat(e, hasToString(containsString(expected)));
+        assertThat(e.getMessage(), containsString(expected));
     }
 
     public void testSpaceInUrl() throws Exception {
         PluginDescriptor pluginZip = createPluginZip("fake", pluginDir);
         Path pluginZipWithSpaces = createTempFile("foo bar", ".zip");
-        try (InputStream in = FileSystemUtils.openFileURLStream(new URL(pluginZip.getUrl()))) {
+        try (InputStream in = FileSystemUtils.openFileURLStream(new URL(pluginZip.getLocation()))) {
             Files.copy(in, pluginZipWithSpaces, StandardCopyOption.REPLACE_EXISTING);
         }
         PluginDescriptor modifiedPlugin = new PluginDescriptor("fake", pluginZipWithSpaces.toUri().toURL().toString());
@@ -463,7 +463,7 @@ public class InstallPluginActionTests extends ESTestCase {
         // has two colons, so it appears similar to maven coordinates
         PluginDescriptor plugin = new PluginDescriptor("fake", "://host:1234");
         MalformedURLException e = expectThrows(MalformedURLException.class, () -> installPlugin(plugin));
-        assertTrue(e.getMessage(), e.getMessage().contains("no protocol"));
+        assertThat(e.getMessage(), containsString("no protocol"));
     }
 
     public void testFileNotMaven() {
@@ -473,13 +473,13 @@ public class InstallPluginActionTests extends ESTestCase {
             // has two colons, so it appears similar to maven coordinates
             () -> installPlugin("file:" + dir)
         );
-        assertFalse(e.getMessage(), e.getMessage().contains("maven.org"));
-        assertTrue(e.getMessage(), e.getMessage().contains(dir));
+        assertThat(e.getMessage(), not(containsString("maven.org")));
+        assertThat(e.getMessage(), containsString(dir));
     }
 
     public void testUnknownPlugin() {
         UserException e = expectThrows(UserException.class, () -> installPlugin("foo"));
-        assertTrue(e.getMessage(), e.getMessage().contains("Unknown plugin foo"));
+        assertThat(e.getMessage(), containsString("Unknown plugin foo"));
     }
 
     public void testPluginsDirReadOnly() throws Exception {
@@ -488,7 +488,7 @@ public class InstallPluginActionTests extends ESTestCase {
             pluginsAttrs.setPermissions(new HashSet<>());
             PluginDescriptor pluginZip = createPluginZip("fake", pluginDir);
             IOException e = expectThrows(IOException.class, () -> installPlugin(pluginZip));
-            assertTrue(e.getMessage(), e.getMessage().contains(env.v2().pluginsFile().toString()));
+            assertThat(e.getMessage(), containsString(env.v2().pluginsFile().toString()));
         }
         assertInstallCleaned(env.v2());
     }
@@ -496,7 +496,7 @@ public class InstallPluginActionTests extends ESTestCase {
     public void testBuiltinModule() throws Exception {
         PluginDescriptor pluginZip = createPluginZip("lang-painless", pluginDir);
         UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip));
-        assertTrue(e.getMessage(), e.getMessage().contains("is a system module"));
+        assertThat(e.getMessage(), containsString("is a system module"));
         assertInstallCleaned(env.v2());
     }
 
@@ -506,7 +506,7 @@ public class InstallPluginActionTests extends ESTestCase {
         // whose descriptor contains the name "x-pack".
         pluginZip.setId("not-x-pack");
         UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip));
-        assertTrue(e.getMessage(), e.getMessage().contains("is a system module"));
+        assertThat(e.getMessage(), containsString("is a system module"));
         assertInstallCleaned(env.v2());
     }
 
@@ -517,7 +517,7 @@ public class InstallPluginActionTests extends ESTestCase {
         writeJar(pluginDirectory.resolve("other.jar"), "FakePlugin");
         PluginDescriptor pluginZip = createPluginZip("fake", pluginDirectory); // adds plugin.jar with FakePlugin
         IllegalStateException e = expectThrows(IllegalStateException.class, () -> installPlugin(pluginZip, env.v1(), defaultAction));
-        assertTrue(e.getMessage(), e.getMessage().contains("jar hell"));
+        assertThat(e.getMessage(), containsString("jar hell"));
         assertInstallCleaned(env.v2());
     }
 
@@ -537,7 +537,7 @@ public class InstallPluginActionTests extends ESTestCase {
         PluginDescriptor pluginZip = createPluginZip("fake", pluginDir);
         installPlugin(pluginZip);
         UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip));
-        assertTrue(e.getMessage(), e.getMessage().contains("already exists"));
+        assertThat(e.getMessage(), containsString("already exists"));
         assertInstallCleaned(env.v2());
     }
 
@@ -555,7 +555,7 @@ public class InstallPluginActionTests extends ESTestCase {
         Files.createFile(binDir);
         PluginDescriptor pluginZip = createPluginZip("fake", pluginDir);
         UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip));
-        assertTrue(e.getMessage(), e.getMessage().contains("not a directory"));
+        assertThat(e.getMessage(), containsString("not a directory"));
         assertInstallCleaned(env.v2());
     }
 
@@ -565,7 +565,7 @@ public class InstallPluginActionTests extends ESTestCase {
         Files.createFile(dirInBinDir.resolve("somescript"));
         PluginDescriptor pluginZip = createPluginZip("fake", pluginDir);
         UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip));
-        assertTrue(e.getMessage(), e.getMessage().contains("Directories not allowed in bin dir for plugin"));
+        assertThat(e.getMessage(), containsString("Directories not allowed in bin dir for plugin"));
         assertInstallCleaned(env.v2());
     }
 
@@ -575,7 +575,7 @@ public class InstallPluginActionTests extends ESTestCase {
         Files.createFile(binDir.resolve("somescript"));
         PluginDescriptor pluginZip = createPluginZip("elasticsearch", pluginDir);
         FileAlreadyExistsException e = expectThrows(FileAlreadyExistsException.class, () -> installPlugin(pluginZip));
-        assertTrue(e.getMessage(), e.getMessage().contains(env.v2().binFile().resolve("elasticsearch").toString()));
+        assertThat(e.getMessage(), containsString(env.v2().binFile().resolve("elasticsearch").toString()));
         assertInstallCleaned(env.v2());
     }
 
@@ -687,7 +687,7 @@ public class InstallPluginActionTests extends ESTestCase {
         Files.createFile(configDir);
         PluginDescriptor pluginZip = createPluginZip("fake", pluginDir);
         UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip));
-        assertTrue(e.getMessage(), e.getMessage().contains("not a directory"));
+        assertThat(e.getMessage(), containsString("not a directory"));
         assertInstallCleaned(env.v2());
     }
 
@@ -697,7 +697,7 @@ public class InstallPluginActionTests extends ESTestCase {
         Files.createFile(dirInConfigDir.resolve("myconfig.yml"));
         PluginDescriptor pluginZip = createPluginZip("fake", pluginDir);
         UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip));
-        assertTrue(e.getMessage(), e.getMessage().contains("Directories not allowed in config dir for plugin"));
+        assertThat(e.getMessage(), containsString("Directories not allowed in config dir for plugin"));
         assertInstallCleaned(env.v2());
     }
 
@@ -705,7 +705,7 @@ public class InstallPluginActionTests extends ESTestCase {
         Files.createFile(pluginDir.resolve("fake.yml"));
         String pluginZip = writeZip(pluginDir, null).toUri().toURL().toString();
         NoSuchFileException e = expectThrows(NoSuchFileException.class, () -> installPlugin(pluginZip));
-        assertTrue(e.getMessage(), e.getMessage().contains("plugin-descriptor.properties"));
+        assertThat(e.getMessage(), containsString("plugin-descriptor.properties"));
         assertInstallCleaned(env.v2());
     }
 
@@ -724,18 +724,13 @@ public class InstallPluginActionTests extends ESTestCase {
         }
         String pluginZip = zip.toUri().toURL().toString();
         UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip));
-        assertTrue(e.getMessage(), e.getMessage().contains("resolving outside of plugin directory"));
+        assertThat(e.getMessage(), containsString("resolving outside of plugin directory"));
         assertInstallCleaned(env.v2());
     }
 
     public void testOfficialPluginsHelpSortedAndMissingObviouslyWrongPlugins() throws Exception {
         MockTerminal terminal = new MockTerminal();
-        new InstallPluginCommand() {
-            @Override
-            protected boolean addShutdownHook() {
-                return false;
-            }
-        }.main(new String[] { "--help" }, terminal);
+        new MockInstallPluginCommand().main(new String[] { "--help" }, terminal);
         try (BufferedReader reader = new BufferedReader(new StringReader(terminal.getOutput()))) {
             String line = reader.readLine();
 
@@ -779,7 +774,7 @@ public class InstallPluginActionTests extends ESTestCase {
             }
         };
         final T exception = expectThrows(clazz, () -> flavorAction.execute(List.of(new PluginDescriptor("x-pack"))));
-        assertThat(exception, hasToString(containsString(expectedMessage)));
+        assertThat(exception.getMessage(), containsString(expectedMessage));
     }
 
     public void testInstallMisspelledOfficialPlugins() {
@@ -831,6 +826,18 @@ public class InstallPluginActionTests extends ESTestCase {
         );
     }
 
+    /**
+     * Check that if the installer action finds a mismatch between what it expects a plugin's ID to be and what
+     * the ID actually is from the plugin's properties, then the installation fails.
+     */
+    public void testPluginHasDifferentNameThatDescriptor() throws Exception {
+        PluginDescriptor descriptor = createPluginZip("fake", pluginDir);
+        PluginDescriptor modifiedDescriptor = new PluginDescriptor("other-fake", descriptor.getLocation());
+
+        final UserException e = expectThrows(UserException.class, () -> installPlugin(modifiedDescriptor));
+        assertThat(e.getMessage(), equalTo("Expected downloaded plugin to have ID [other-fake] but found [fake]"));
+    }
+
     private void installPlugin(boolean isBatch, String... additionalProperties) throws Exception {
         // if batch is enabled, we also want to add a security policy
         if (isBatch) {
@@ -842,10 +849,36 @@ public class InstallPluginActionTests extends ESTestCase {
         skipJarHellAction.execute(List.of(pluginZip));
     }
 
+    private void assertInstallPluginFromUrl(final String pluginId, final String url, final String stagingHash, boolean isSnapshot)
+        throws Exception {
+        assertInstallPluginFromUrl(pluginId, null, url, stagingHash, isSnapshot);
+    }
+
+    private void assertInstallPluginFromUrl(
+        final String pluginId,
+        final String pluginUrl,
+        final String url,
+        final String stagingHash,
+        boolean isSnapshot
+    ) throws Exception {
+        final MessageDigest digest = MessageDigest.getInstance("SHA-512");
+        assertInstallPluginFromUrl(
+            pluginId,
+            pluginUrl,
+            url,
+            stagingHash,
+            isSnapshot,
+            ".sha512",
+            checksumAndFilename(digest, url),
+            newSecretKey(),
+            this::signature
+        );
+    }
+
     @SuppressForbidden(reason = "Path.of() is OK in this context")
     void assertInstallPluginFromUrl(
         final String pluginId,
-        final String name,
+        final String pluginUrl,
         final String url,
         final String stagingHash,
         final boolean isSnapshot,
@@ -854,8 +887,8 @@ public class InstallPluginActionTests extends ESTestCase {
         final PGPSecretKey secretKey,
         final BiFunction<byte[], PGPSecretKey, String> signature
     ) throws Exception {
-        PluginDescriptor pluginZip = createPlugin(name, pluginDir);
-        Path pluginZipPath = Path.of(URI.create(pluginZip.getUrl()));
+        PluginDescriptor pluginZip = createPlugin(pluginId, pluginDir);
+        Path pluginZipPath = Path.of(URI.create(pluginZip.getLocation()));
         InstallPluginAction action = new InstallPluginAction(terminal, env.v2(), false) {
             @Override
             Path downloadZip(String urlString, Path tmpDir) throws IOException {
@@ -868,7 +901,7 @@ public class InstallPluginActionTests extends ESTestCase {
             @Override
             URL openUrl(String urlString) throws IOException {
                 if ((url + shaExtension).equals(urlString)) {
-                    // calc sha an return file URL to it
+                    // calc sha and return file URL to it
                     Path shaFile = temp.apply("shas").resolve("downloaded.zip" + shaExtension);
                     byte[] zipbytes = Files.readAllBytes(pluginZipPath);
                     String checksum = shaCalculator.apply(zipbytes);
@@ -886,7 +919,7 @@ public class InstallPluginActionTests extends ESTestCase {
 
             @Override
             void verifySignature(Path zip, String urlString) throws IOException, PGPException {
-                if (InstallPluginAction.OFFICIAL_PLUGINS.contains(name)) {
+                if (InstallPluginAction.OFFICIAL_PLUGINS.contains(pluginId)) {
                     super.verifySignature(zip, urlString);
                 } else {
                     throw new UnsupportedOperationException("verify signature should not be called for unofficial plugins");
@@ -936,36 +969,15 @@ public class InstallPluginActionTests extends ESTestCase {
                 // no jarhell check
             }
         };
-        installPlugin(new PluginDescriptor(name, pluginId), env.v1(), action);
-        assertPlugin(name, pluginDir, env.v2());
-    }
-
-    public void assertInstallPluginFromUrl(
-        final String pluginId,
-        final String name,
-        final String url,
-        final String stagingHash,
-        boolean isSnapshot
-    ) throws Exception {
-        final MessageDigest digest = MessageDigest.getInstance("SHA-512");
-        assertInstallPluginFromUrl(
-            pluginId,
-            name,
-            url,
-            stagingHash,
-            isSnapshot,
-            ".sha512",
-            checksumAndFilename(digest, url),
-            newSecretKey(),
-            this::signature
-        );
+        installPlugin(new PluginDescriptor(pluginId, pluginUrl), env.v1(), action);
+        assertPlugin(pluginId, pluginDir, env.v2());
     }
 
     public void testOfficialPlugin() throws Exception {
         String url = "https://artifacts.elastic.co/downloads/elasticsearch-plugins/analysis-icu/analysis-icu-"
             + Build.CURRENT.getQualifiedVersion()
             + ".zip";
-        assertInstallPluginFromUrl("analysis-icu", "analysis-icu", url, null, false);
+        assertInstallPluginFromUrl("analysis-icu", url, null, false);
     }
 
     public void testOfficialPluginSnapshot() throws Exception {
@@ -975,7 +987,7 @@ public class InstallPluginActionTests extends ESTestCase {
             Version.CURRENT,
             Build.CURRENT.getQualifiedVersion()
         );
-        assertInstallPluginFromUrl("analysis-icu", "analysis-icu", url, "abc123", true);
+        assertInstallPluginFromUrl("analysis-icu", url, "abc123", true);
     }
 
     public void testInstallReleaseBuildOfPluginOnSnapshotBuild() {
@@ -985,15 +997,15 @@ public class InstallPluginActionTests extends ESTestCase {
             Version.CURRENT,
             Build.CURRENT.getQualifiedVersion()
         );
-        // attemping to install a release build of a plugin (no staging ID) on a snapshot build should throw a user exception
+        // attempting to install a release build of a plugin (no staging ID) on a snapshot build should throw a user exception
         final UserException e = expectThrows(
             UserException.class,
             () -> assertInstallPluginFromUrl("analysis-icu", "analysis-icu", url, null, true)
         );
         assertThat(e.exitCode, equalTo(ExitCodes.CONFIG));
         assertThat(
-            e,
-            hasToString(containsString("attempted to install release build of official plugin on snapshot build of Elasticsearch"))
+            e.getMessage(),
+            containsString("attempted to install release build of official plugin on snapshot build of Elasticsearch")
         );
     }
 
@@ -1003,7 +1015,7 @@ public class InstallPluginActionTests extends ESTestCase {
             + "-abc123/downloads/elasticsearch-plugins/analysis-icu/analysis-icu-"
             + Build.CURRENT.getQualifiedVersion()
             + ".zip";
-        assertInstallPluginFromUrl("analysis-icu", "analysis-icu", url, "abc123", false);
+        assertInstallPluginFromUrl("analysis-icu", url, "abc123", false);
     }
 
     public void testOfficialPlatformPlugin() throws Exception {
@@ -1012,7 +1024,7 @@ public class InstallPluginActionTests extends ESTestCase {
             + "-"
             + Build.CURRENT.getQualifiedVersion()
             + ".zip";
-        assertInstallPluginFromUrl("analysis-icu", "analysis-icu", url, null, false);
+        assertInstallPluginFromUrl("analysis-icu", url, null, false);
     }
 
     public void testOfficialPlatformPluginSnapshot() throws Exception {
@@ -1023,7 +1035,7 @@ public class InstallPluginActionTests extends ESTestCase {
             Platforms.PLATFORM_NAME,
             Build.CURRENT.getQualifiedVersion()
         );
-        assertInstallPluginFromUrl("analysis-icu", "analysis-icu", url, "abc123", true);
+        assertInstallPluginFromUrl("analysis-icu", url, "abc123", true);
     }
 
     public void testOfficialPlatformPluginStaging() throws Exception {
@@ -1034,23 +1046,23 @@ public class InstallPluginActionTests extends ESTestCase {
             + "-"
             + Build.CURRENT.getQualifiedVersion()
             + ".zip";
-        assertInstallPluginFromUrl("analysis-icu", "analysis-icu", url, "abc123", false);
+        assertInstallPluginFromUrl("analysis-icu", url, "abc123", false);
     }
 
     public void testMavenPlugin() throws Exception {
         String url = "https://repo1.maven.org/maven2/mygroup/myplugin/1.0.0/myplugin-1.0.0.zip";
-        assertInstallPluginFromUrl("mygroup:myplugin:1.0.0", "myplugin", url, null, false);
+        assertInstallPluginFromUrl("myplugin", "mygroup:myplugin:1.0.0", url, null, false);
     }
 
     public void testMavenPlatformPlugin() throws Exception {
         String url = "https://repo1.maven.org/maven2/mygroup/myplugin/1.0.0/myplugin-" + Platforms.PLATFORM_NAME + "-1.0.0.zip";
-        assertInstallPluginFromUrl("mygroup:myplugin:1.0.0", "myplugin", url, null, false);
+        assertInstallPluginFromUrl("myplugin", "mygroup:myplugin:1.0.0", url, null, false);
     }
 
     public void testMavenSha1Backcompat() throws Exception {
         String url = "https://repo1.maven.org/maven2/mygroup/myplugin/1.0.0/myplugin-1.0.0.zip";
         MessageDigest digest = MessageDigest.getInstance("SHA-1");
-        assertInstallPluginFromUrl("mygroup:myplugin:1.0.0", "myplugin", url, null, false, ".sha1", checksum(digest), null, (b, p) -> null);
+        assertInstallPluginFromUrl("myplugin", "mygroup:myplugin:1.0.0", url, null, false, ".sha1", checksum(digest), null, (b, p) -> null);
         assertTrue(terminal.getOutput(), terminal.getOutput().contains("sha512 not found, falling back to sha1"));
     }
 
@@ -1058,8 +1070,8 @@ public class InstallPluginActionTests extends ESTestCase {
         String url = "https://repo1.maven.org/maven2/mygroup/myplugin/1.0.0/myplugin-1.0.0.zip";
         MessageDigest digest = MessageDigest.getInstance("SHA-512");
         assertInstallPluginFromUrl(
-            "mygroup:myplugin:1.0.0",
             "myplugin",
+            "mygroup:myplugin:1.0.0",
             url,
             null,
             false,
@@ -1077,17 +1089,7 @@ public class InstallPluginActionTests extends ESTestCase {
         MessageDigest digest = MessageDigest.getInstance("SHA-512");
         UserException e = expectThrows(
             UserException.class,
-            () -> assertInstallPluginFromUrl(
-                "analysis-icu",
-                "analysis-icu",
-                url,
-                null,
-                false,
-                ".sha512",
-                checksum(digest),
-                null,
-                (b, p) -> null
-            )
+            () -> assertInstallPluginFromUrl("analysis-icu", null, url, null, false, ".sha512", checksum(digest), null, (b, p) -> null)
         );
         assertEquals(ExitCodes.IO_ERROR, e.exitCode);
         assertThat(e.getMessage(), startsWith("Invalid checksum file"));
@@ -1100,20 +1102,10 @@ public class InstallPluginActionTests extends ESTestCase {
         MessageDigest digest = MessageDigest.getInstance("SHA-1");
         UserException e = expectThrows(
             UserException.class,
-            () -> assertInstallPluginFromUrl(
-                "analysis-icu",
-                "analysis-icu",
-                url,
-                null,
-                false,
-                ".sha1",
-                checksum(digest),
-                null,
-                (b, p) -> null
-            )
+            () -> assertInstallPluginFromUrl("analysis-icu", null, url, null, false, ".sha1", checksum(digest), null, (b, p) -> null)
         );
         assertEquals(ExitCodes.IO_ERROR, e.exitCode);
-        assertEquals("Plugin checksum missing: " + url + ".sha512", e.getMessage());
+        assertThat(e.getMessage(), equalTo("Plugin checksum missing: " + url + ".sha512"));
     }
 
     public void testMavenShaMissing() {
@@ -1121,8 +1113,8 @@ public class InstallPluginActionTests extends ESTestCase {
         UserException e = expectThrows(
             UserException.class,
             () -> assertInstallPluginFromUrl(
-                "mygroup:myplugin:1.0.0",
                 "myplugin",
+                "mygroup:myplugin:1.0.0",
                 url,
                 null,
                 false,
@@ -1133,7 +1125,7 @@ public class InstallPluginActionTests extends ESTestCase {
             )
         );
         assertEquals(ExitCodes.IO_ERROR, e.exitCode);
-        assertEquals("Plugin checksum missing: " + url + ".sha1", e.getMessage());
+        assertThat(e.getMessage(), equalTo("Plugin checksum missing: " + url + ".sha1"));
     }
 
     public void testInvalidShaFileMissingFilename() throws Exception {
@@ -1143,20 +1135,10 @@ public class InstallPluginActionTests extends ESTestCase {
         MessageDigest digest = MessageDigest.getInstance("SHA-512");
         UserException e = expectThrows(
             UserException.class,
-            () -> assertInstallPluginFromUrl(
-                "analysis-icu",
-                "analysis-icu",
-                url,
-                null,
-                false,
-                ".sha512",
-                checksum(digest),
-                null,
-                (b, p) -> null
-            )
+            () -> assertInstallPluginFromUrl("analysis-icu", null, url, null, false, ".sha512", checksum(digest), null, (b, p) -> null)
         );
         assertEquals(ExitCodes.IO_ERROR, e.exitCode);
-        assertTrue(e.getMessage(), e.getMessage().startsWith("Invalid checksum file"));
+        assertThat(e.getMessage(), containsString("Invalid checksum file"));
     }
 
     public void testInvalidShaFileMismatchFilename() throws Exception {
@@ -1168,7 +1150,7 @@ public class InstallPluginActionTests extends ESTestCase {
             UserException.class,
             () -> assertInstallPluginFromUrl(
                 "analysis-icu",
-                "analysis-icu",
+                null,
                 url,
                 null,
                 false,
@@ -1191,7 +1173,7 @@ public class InstallPluginActionTests extends ESTestCase {
             UserException.class,
             () -> assertInstallPluginFromUrl(
                 "analysis-icu",
-                "analysis-icu",
+                null,
                 url,
                 null,
                 false,
@@ -1202,7 +1184,7 @@ public class InstallPluginActionTests extends ESTestCase {
             )
         );
         assertEquals(ExitCodes.IO_ERROR, e.exitCode);
-        assertTrue(e.getMessage(), e.getMessage().startsWith("Invalid checksum file"));
+        assertThat(e.getMessage(), containsString("Invalid checksum file"));
     }
 
     public void testSha512Mismatch() {
@@ -1213,7 +1195,7 @@ public class InstallPluginActionTests extends ESTestCase {
             UserException.class,
             () -> assertInstallPluginFromUrl(
                 "analysis-icu",
-                "analysis-icu",
+                null,
                 url,
                 null,
                 false,
@@ -1224,7 +1206,7 @@ public class InstallPluginActionTests extends ESTestCase {
             )
         );
         assertEquals(ExitCodes.IO_ERROR, e.exitCode);
-        assertTrue(e.getMessage(), e.getMessage().contains("SHA-512 mismatch, expected foobar"));
+        assertThat(e.getMessage(), containsString("SHA-512 mismatch, expected foobar"));
     }
 
     public void testSha1Mismatch() {
@@ -1232,8 +1214,8 @@ public class InstallPluginActionTests extends ESTestCase {
         UserException e = expectThrows(
             UserException.class,
             () -> assertInstallPluginFromUrl(
-                "mygroup:myplugin:1.0.0",
                 "myplugin",
+                "mygroup:myplugin:1.0.0",
                 url,
                 null,
                 false,
@@ -1244,7 +1226,7 @@ public class InstallPluginActionTests extends ESTestCase {
             )
         );
         assertEquals(ExitCodes.IO_ERROR, e.exitCode);
-        assertTrue(e.getMessage(), e.getMessage().contains("SHA-1 mismatch, expected foobar"));
+        assertThat(e.getMessage(), containsString("SHA-1 mismatch, expected foobar"));
     }
 
     public void testPublicKeyIdMismatchToExpectedPublicKeyId() throws Exception {
@@ -1269,7 +1251,7 @@ public class InstallPluginActionTests extends ESTestCase {
             IllegalStateException.class,
             () -> assertInstallPluginFromUrl(
                 icu,
-                icu,
+                null,
                 url,
                 null,
                 false,
@@ -1279,7 +1261,7 @@ public class InstallPluginActionTests extends ESTestCase {
                 signature
             )
         );
-        assertThat(e, hasToString(containsString("key id [" + actualID + "] does not match expected key id [" + expectedID + "]")));
+        assertThat(e.getMessage(), containsString("key id [" + actualID + "] does not match expected key id [" + expectedID + "]"));
     }
 
     public void testFailedSignatureVerification() throws Exception {
@@ -1304,7 +1286,7 @@ public class InstallPluginActionTests extends ESTestCase {
             IllegalStateException.class,
             () -> assertInstallPluginFromUrl(
                 icu,
-                icu,
+                null,
                 url,
                 null,
                 false,
@@ -1314,7 +1296,7 @@ public class InstallPluginActionTests extends ESTestCase {
                 signature
             )
         );
-        assertThat(e, hasToString(equalTo("java.lang.IllegalStateException: signature verification for [" + url + "] failed")));
+        assertThat(e.getMessage(), containsString("signature verification for [" + url + "] failed"));
     }
 
     public PGPSecretKey newSecretKey() throws NoSuchAlgorithmException, PGPException {
@@ -1370,7 +1352,7 @@ public class InstallPluginActionTests extends ESTestCase {
                 }
                 generator.generate().encode(pout);
             }
-            return new String(output.toByteArray(), "UTF-8");
+            return output.toString(StandardCharsets.UTF_8);
         } catch (IOException | PGPException e) {
             throw new RuntimeException(e);
         }
@@ -1387,7 +1369,7 @@ public class InstallPluginActionTests extends ESTestCase {
             // default answer, does not install
             terminal.addTextInput("");
             UserException e = expectThrows(UserException.class, () -> installPlugin(pluginZip));
-            assertEquals("installation aborted by user", e.getMessage());
+            assertThat(e.getMessage(), containsString("installation aborted by user"));
 
             assertThat(terminal.getErrorOutput(), containsString("WARNING: " + warning));
             try (Stream<Path> fileStream = Files.list(env.v2().pluginsFile())) {
@@ -1401,7 +1383,7 @@ public class InstallPluginActionTests extends ESTestCase {
             }
             terminal.addTextInput("n");
             e = expectThrows(UserException.class, () -> installPlugin(pluginZip));
-            assertEquals("installation aborted by user", e.getMessage());
+            assertThat(e.getMessage(), containsString("installation aborted by user"));
             assertThat(terminal.getErrorOutput(), containsString("WARNING: " + warning));
             try (Stream<Path> fileStream = Files.list(env.v2().pluginsFile())) {
                 assertThat(fileStream.collect(Collectors.toList()), empty());
@@ -1431,7 +1413,7 @@ public class InstallPluginActionTests extends ESTestCase {
         PluginDescriptor pluginZip = createPluginZip("fake", pluginDir, "has.native.controller", "true");
 
         final IllegalStateException e = expectThrows(IllegalStateException.class, () -> installPlugin(pluginZip));
-        assertThat(e, hasToString(containsString("plugins can not have native controllers")));
+        assertThat(e.getMessage(), containsString("plugins can not have native controllers"));
     }
 
     public void testMultipleJars() throws Exception {

+ 36 - 0
distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/cli/MockInstallPluginCommand.java

@@ -0,0 +1,36 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugins.cli;
+
+import org.elasticsearch.cli.UserException;
+import org.elasticsearch.env.Environment;
+
+import java.util.Map;
+
+public class MockInstallPluginCommand extends InstallPluginCommand {
+    private final Environment env;
+
+    public MockInstallPluginCommand(Environment env) {
+        this.env = env;
+    }
+
+    public MockInstallPluginCommand() {
+        this.env = null;
+    }
+
+    @Override
+    protected Environment createEnv(Map<String, String> settings) throws UserException {
+        return this.env != null ? this.env : super.createEnv(settings);
+    }
+
+    @Override
+    protected boolean addShutdownHook() {
+        return false;
+    }
+}

+ 26 - 0
distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/cli/MockRemovePluginCommand.java

@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugins.cli;
+
+import org.elasticsearch.env.Environment;
+
+import java.util.Map;
+
+public class MockRemovePluginCommand extends RemovePluginCommand {
+    final Environment env;
+
+    public MockRemovePluginCommand(final Environment env) {
+        this.env = env;
+    }
+
+    @Override
+    protected Environment createEnv(Map<String, String> settings) {
+        return env;
+    }
+}

+ 57 - 0
distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/cli/ProxyMatcher.java

@@ -0,0 +1,57 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugins.cli;
+
+import org.elasticsearch.cli.SuppressForbidden;
+import org.hamcrest.Description;
+import org.hamcrest.TypeSafeMatcher;
+
+import java.net.InetSocketAddress;
+import java.net.Proxy;
+
+class ProxyMatcher extends TypeSafeMatcher<Proxy> {
+    private final Proxy.Type type;
+    private final String hostname;
+    private final int port;
+
+    public static ProxyMatcher matchesProxy(Proxy.Type type, String hostname, int port) {
+        return new ProxyMatcher(type, hostname, port);
+    }
+
+    public static ProxyMatcher matchesProxy(Proxy.Type type) {
+        return new ProxyMatcher(type, null, -1);
+    }
+
+    ProxyMatcher(Proxy.Type type, String hostname, int port) {
+        this.type = type;
+        this.hostname = hostname;
+        this.port = port;
+    }
+
+    @Override
+    @SuppressForbidden(reason = "Proxy constructor uses InetSocketAddress")
+    protected boolean matchesSafely(Proxy proxy) {
+        if (proxy.type() != this.type) {
+            return false;
+        }
+
+        if (hostname == null) {
+            return true;
+        }
+
+        InetSocketAddress address = (InetSocketAddress) proxy.address();
+
+        return this.hostname.equals(address.getHostName()) && this.port == address.getPort();
+    }
+
+    @Override
+    public void describeTo(Description description) {
+        description.appendText("a proxy instance of type [" + type + "] pointing at [" + hostname + ":" + port + "]");
+    }
+}

+ 55 - 0
distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/cli/ProxyUtilsTests.java

@@ -0,0 +1,55 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugins.cli;
+
+import org.elasticsearch.cli.UserException;
+import org.elasticsearch.test.ESTestCase;
+
+import java.net.Proxy.Type;
+import java.util.stream.Stream;
+
+import static org.elasticsearch.plugins.cli.ProxyMatcher.matchesProxy;
+import static org.elasticsearch.plugins.cli.ProxyUtils.buildProxy;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+
+public class ProxyUtilsTests extends ESTestCase {
+    /**
+     * Check that building a proxy with just a hostname and port succeeds.
+     */
+    public void testBuildProxy_withHostPort() throws Exception {
+        assertThat(buildProxy("host:1234"), matchesProxy(Type.HTTP, "host", 1234));
+    }
+
+    /**
+     * Check that building a proxy with a null value succeeds, returning a pass-through (direct) proxy.
+     */
+    public void testBuildProxy_withNullValue() throws Exception {
+        assertThat(buildProxy(null), is(nullValue()));
+    }
+
+    /**
+     * Check that building a proxy with a missing host is rejected.
+     */
+    public void testBuildProxy_withMissingHost() {
+        UserException e = expectThrows(UserException.class, () -> buildProxy(":1234"));
+        assertThat(e.getMessage(), equalTo("Malformed [proxy], expected [host:port]"));
+    }
+
+    /**
+     * Check that building a proxy with a missing or invalid port is rejected.
+     */
+    public void testBuildProxy_withInvalidPort() {
+        Stream.of("host:", "host.domain:-1", "host.domain:$PORT", "host.domain:{{port}}", "host.domain").forEach(testCase -> {
+            UserException e = expectThrows(UserException.class, () -> buildProxy(testCase));
+            assertThat(e.getMessage(), equalTo("Malformed [proxy], expected [host:port]"));
+        });
+    }
+}

+ 4 - 19
distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/cli/RemovePluginActionTests.java

@@ -28,7 +28,6 @@ import java.nio.file.DirectoryStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.List;
-import java.util.Map;
 import java.util.stream.Collectors;
 
 import static java.util.Collections.emptyList;
@@ -36,7 +35,6 @@ import static org.hamcrest.CoreMatchers.containsString;
 import static org.hamcrest.CoreMatchers.not;
 import static org.hamcrest.CoreMatchers.nullValue;
 import static org.hamcrest.Matchers.equalTo;
-import static org.hamcrest.Matchers.hasToString;
 
 @LuceneTestCase.SuppressFileSystems("*")
 public class RemovePluginActionTests extends ESTestCase {
@@ -44,19 +42,6 @@ public class RemovePluginActionTests extends ESTestCase {
     private Path home;
     private Environment env;
 
-    static class MockRemovePluginCommand extends RemovePluginCommand {
-        final Environment env;
-
-        private MockRemovePluginCommand(final Environment env) {
-            this.env = env;
-        }
-
-        @Override
-        protected Environment createEnv(Map<String, String> settings) throws UserException {
-            return env;
-        }
-    }
-
     @Override
     @Before
     public void setUp() throws Exception {
@@ -121,7 +106,7 @@ public class RemovePluginActionTests extends ESTestCase {
 
     public void testMissing() throws Exception {
         UserException e = expectThrows(UserException.class, () -> removePlugin("dne", home, randomBoolean()));
-        assertTrue(e.getMessage(), e.getMessage().contains("plugin [dne] not found"));
+        assertThat(e.getMessage(), containsString("plugin [dne] not found"));
         assertRemoveCleaned(env);
     }
 
@@ -182,7 +167,7 @@ public class RemovePluginActionTests extends ESTestCase {
         createPlugin("fake");
         Files.createFile(env.binFile().resolve("fake"));
         UserException e = expectThrows(UserException.class, () -> removePlugin("fake", home, randomBoolean()));
-        assertTrue(e.getMessage(), e.getMessage().contains("not a directory"));
+        assertThat(e.getMessage(), containsString("not a directory"));
         assertTrue(Files.exists(env.pluginsFile().resolve("fake"))); // did not remove
         assertTrue(Files.exists(env.binFile().resolve("fake")));
         assertRemoveCleaned(env);
@@ -224,7 +209,7 @@ public class RemovePluginActionTests extends ESTestCase {
 
     public void testPurgeNothingExists() throws Exception {
         final UserException e = expectThrows(UserException.class, () -> removePlugin("fake", home, true));
-        assertThat(e, hasToString(containsString("plugin [fake] not found")));
+        assertThat(e.getMessage(), containsString("plugin [fake] not found"));
     }
 
     public void testPurgeOnlyMarkerFileExists() throws Exception {
@@ -276,7 +261,7 @@ public class RemovePluginActionTests extends ESTestCase {
 
         e = expectThrows(UserException.class, () -> removePlugin(emptyList(), home, randomBoolean()));
         assertEquals(ExitCodes.USAGE, e.exitCode);
-        assertEquals("At least one plugin ID is required", e.getMessage());
+        assertThat(e.getMessage(), equalTo("At least one plugin ID is required"));
     }
 
     public void testRemoveWhenRemovingMarker() throws Exception {

+ 296 - 0
distribution/tools/plugin-cli/src/test/java/org/elasticsearch/plugins/cli/SyncPluginsActionTests.java

@@ -0,0 +1,296 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+package org.elasticsearch.plugins.cli;
+
+import org.apache.lucene.util.LuceneTestCase;
+import org.elasticsearch.Version;
+import org.elasticsearch.cli.MockTerminal;
+import org.elasticsearch.cli.UserException;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.env.TestEnvironment;
+import org.elasticsearch.plugins.PluginTestUtil;
+import org.elasticsearch.plugins.cli.SyncPluginsAction.PluginChanges;
+import org.elasticsearch.test.ESTestCase;
+import org.hamcrest.Matchers;
+import org.junit.Before;
+import org.mockito.InOrder;
+import org.mockito.Mockito;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Optional;
+
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+@LuceneTestCase.SuppressFileSystems("*")
+public class SyncPluginsActionTests extends ESTestCase {
+    private Environment env;
+    private SyncPluginsAction action;
+    private PluginsConfig config;
+
+    @Override
+    @Before
+    public void setUp() throws Exception {
+        super.setUp();
+        Path home = createTempDir();
+        Settings settings = Settings.builder().put("path.home", home).build();
+        env = TestEnvironment.newEnvironment(settings);
+        Files.createDirectories(env.binFile());
+        Files.createFile(env.binFile().resolve("elasticsearch"));
+        Files.createDirectories(env.configFile());
+        Files.createDirectories(env.pluginsFile());
+
+        action = new SyncPluginsAction(new MockTerminal(), env);
+        config = new PluginsConfig();
+    }
+
+    /**
+     * Check that when we ensure a plugins config file doesn't exist, and it really doesn't exist,
+     * then no exception is thrown.
+     */
+    public void test_ensureNoConfigFile_withoutConfig_doesNothing() throws Exception {
+        SyncPluginsAction.ensureNoConfigFile(env);
+    }
+
+    /**
+     * Check that when we ensure a plugins config file doesn't exist, but a file does exist,
+     * then an exception is thrown.
+     */
+    public void test_ensureNoConfigFile_withConfig_throwsException() throws Exception {
+        Files.createFile(env.configFile().resolve("elasticsearch-plugins.yml"));
+        final UserException e = expectThrows(UserException.class, () -> SyncPluginsAction.ensureNoConfigFile(env));
+
+        assertThat(e.getMessage(), Matchers.matchesPattern("^Plugins config \\[.*] exists.*$"));
+    }
+
+    /**
+     * Check that when there are no plugins to install, and no plugins already installed, then we
+     * calculate that no changes are required.
+     */
+    public void test_getPluginChanges_withNoChanges_returnsNoChanges() throws PluginSyncException {
+        final SyncPluginsAction.PluginChanges pluginChanges = action.getPluginChanges(config, Optional.empty());
+
+        assertThat(pluginChanges.isEmpty(), is(true));
+    }
+
+    /**
+     * Check that when there are no plugins in the config file, and a plugin is already installed, then we
+     * calculate that the plugin needs to be removed.
+     */
+    public void test_getPluginChanges_withExtraPluginOnDisk_returnsPluginToRemove() throws Exception {
+        createPlugin("my-plugin");
+
+        final PluginChanges pluginChanges = action.getPluginChanges(config, Optional.empty());
+
+        assertThat(pluginChanges.isEmpty(), is(false));
+        assertThat(pluginChanges.install, empty());
+        assertThat(pluginChanges.remove, hasSize(1));
+        assertThat(pluginChanges.upgrade, empty());
+        assertThat(pluginChanges.remove.get(0).getId(), equalTo("my-plugin"));
+    }
+
+    /**
+     * Check that when there is a plugin in the config file, and no plugins already installed, then we
+     * calculate that the plugin needs to be installed.
+     */
+    public void test_getPluginChanges_withPluginToInstall_returnsPluginToInstall() throws Exception {
+        config.setPlugins(List.of(new PluginDescriptor("my-plugin")));
+
+        final PluginChanges pluginChanges = action.getPluginChanges(config, Optional.empty());
+
+        assertThat(pluginChanges.isEmpty(), is(false));
+        assertThat(pluginChanges.install, hasSize(1));
+        assertThat(pluginChanges.remove, empty());
+        assertThat(pluginChanges.upgrade, empty());
+        assertThat(pluginChanges.install.get(0).getId(), equalTo("my-plugin"));
+    }
+
+    /**
+     * Check that when there is an unofficial plugin in the config file, and that plugin is already installed
+     * but needs to be upgraded due to the Elasticsearch version, then we calculate that no changes are required,
+     * since we can't automatically upgrade it.
+     */
+    public void test_getPluginChanges_withPluginToUpgrade_returnsNoChanges() throws Exception {
+        createPlugin("my-plugin", Version.CURRENT.previousMajor());
+        config.setPlugins(List.of(new PluginDescriptor("my-plugin")));
+
+        final PluginChanges pluginChanges = action.getPluginChanges(config, Optional.empty());
+
+        assertThat(pluginChanges.isEmpty(), is(true));
+    }
+
+    /**
+     * Check that when there is an official plugin in the config file, and that plugin is already installed
+     * but needs to be upgraded, then we calculate that the plugin needs to be upgraded.
+     */
+    public void test_getPluginChanges_withOfficialPluginToUpgrade_returnsPluginToUpgrade() throws Exception {
+        createPlugin("analysis-icu", Version.CURRENT.previousMajor());
+        config.setPlugins(List.of(new PluginDescriptor("analysis-icu")));
+
+        final PluginChanges pluginChanges = action.getPluginChanges(config, Optional.empty());
+
+        assertThat(pluginChanges.isEmpty(), is(false));
+        assertThat(pluginChanges.install, empty());
+        assertThat(pluginChanges.remove, empty());
+        assertThat(pluginChanges.upgrade, hasSize(1));
+        assertThat(pluginChanges.upgrade.get(0).getId(), equalTo("analysis-icu"));
+    }
+
+    /**
+     * Check that if an unofficial plugins' location has not changed in the cached config, then we
+     * calculate that the plugin does not need to be upgraded.
+     */
+    public void test_getPluginChanges_withCachedConfigAndNoChanges_returnsNoChanges() throws Exception {
+        createPlugin("my-plugin");
+        config.setPlugins(List.of(new PluginDescriptor("my-plugin", "file://plugin.zip")));
+
+        final PluginsConfig cachedConfig = new PluginsConfig();
+        cachedConfig.setPlugins(List.of(new PluginDescriptor("my-plugin", "file://plugin.zip")));
+
+        final PluginChanges pluginChanges = action.getPluginChanges(config, Optional.of(cachedConfig));
+
+        assertThat(pluginChanges.isEmpty(), is(true));
+    }
+
+    /**
+     * Check that if an unofficial plugins' location has changed, then we calculate that the plugin
+     * needs to be upgraded.
+     */
+    public void test_getPluginChanges_withCachedConfigAndChangedLocation_returnsPluginToUpgrade() throws Exception {
+        createPlugin("my-plugin");
+        config.setPlugins(List.of(new PluginDescriptor("my-plugin", "file:///after.zip")));
+
+        final PluginsConfig cachedConfig = new PluginsConfig();
+        cachedConfig.setPlugins(List.of(new PluginDescriptor("my-plugin", "file://before.zip")));
+
+        final PluginChanges pluginChanges = action.getPluginChanges(config, Optional.of(cachedConfig));
+
+        assertThat(pluginChanges.isEmpty(), is(false));
+        assertThat(pluginChanges.install, empty());
+        assertThat(pluginChanges.remove, empty());
+        assertThat(pluginChanges.upgrade, hasSize(1));
+        assertThat(pluginChanges.upgrade.get(0).getId(), equalTo("my-plugin"));
+    }
+
+    /**
+     * Check that if there are no changes to apply, then the install and remove actions are not used.
+     * This is a redundant test, really, because the sync action exits early if there are no
+     * changes.
+     */
+    public void test_performSync_withNoChanges_doesNothing() throws Exception {
+        final InstallPluginAction installAction = mock(InstallPluginAction.class);
+        final RemovePluginAction removeAction = mock(RemovePluginAction.class);
+
+        action.performSync(installAction, removeAction, new PluginChanges(List.of(), List.of(), List.of()));
+
+        verify(installAction, never()).execute(anyList());
+        verify(removeAction, never()).execute(anyList());
+    }
+
+    /**
+     * Check that if there are plugins to remove, then the remove action is used.
+     */
+    public void test_performSync_withPluginsToRemove_callsRemoveAction() throws Exception {
+        final InstallPluginAction installAction = mock(InstallPluginAction.class);
+        final RemovePluginAction removeAction = mock(RemovePluginAction.class);
+        final List<PluginDescriptor> pluginDescriptors = List.of(new PluginDescriptor("plugin1"), new PluginDescriptor("plugin2"));
+
+        action.performSync(installAction, removeAction, new PluginChanges(pluginDescriptors, List.of(), List.of()));
+
+        verify(installAction, never()).execute(anyList());
+        verify(removeAction).setPurge(true);
+        verify(removeAction).execute(pluginDescriptors);
+    }
+
+    /**
+     * Check that if there are plugins to install, then the install action is used.
+     */
+    public void test_performSync_withPluginsToInstall_callsInstallAction() throws Exception {
+        final InstallPluginAction installAction = mock(InstallPluginAction.class);
+        final RemovePluginAction removeAction = mock(RemovePluginAction.class);
+        final List<PluginDescriptor> pluginDescriptors = List.of(new PluginDescriptor("plugin1"), new PluginDescriptor("plugin2"));
+
+        action.performSync(installAction, removeAction, new PluginChanges(List.of(), pluginDescriptors, List.of()));
+
+        verify(installAction).execute(pluginDescriptors);
+        verify(removeAction, never()).execute(anyList());
+    }
+
+    /**
+     * Check that if there are plugins to upgrade, then both the install and remove actions are used.
+     */
+    public void test_performSync_withPluginsToUpgrade_callsRemoveAndInstallAction() throws Exception {
+        final InstallPluginAction installAction = mock(InstallPluginAction.class);
+        final RemovePluginAction removeAction = mock(RemovePluginAction.class);
+        final InOrder inOrder = Mockito.inOrder(removeAction, installAction);
+
+        final List<PluginDescriptor> pluginDescriptors = List.of(new PluginDescriptor("plugin1"), new PluginDescriptor("plugin2"));
+
+        action.performSync(installAction, removeAction, new PluginChanges(List.of(), List.of(), pluginDescriptors));
+
+        inOrder.verify(removeAction).setPurge(false);
+        inOrder.verify(removeAction).execute(pluginDescriptors);
+        inOrder.verify(installAction).execute(pluginDescriptors);
+    }
+
+    /**
+     * Check that if there are plugins to remove, install and upgrade, then we do everything.
+     */
+    public void test_performSync_withPluginsToUpgrade_callsUpgradeAction() throws Exception {
+        final InstallPluginAction installAction = mock(InstallPluginAction.class);
+        final RemovePluginAction removeAction = mock(RemovePluginAction.class);
+        final InOrder inOrder = Mockito.inOrder(removeAction, installAction);
+
+        final List<PluginDescriptor> pluginsToRemove = List.of(new PluginDescriptor("plugin1"));
+        final List<PluginDescriptor> pluginsToInstall = List.of(new PluginDescriptor("plugin2"));
+        final List<PluginDescriptor> pluginsToUpgrade = List.of(new PluginDescriptor("plugin3"));
+
+        action.performSync(installAction, removeAction, new PluginChanges(pluginsToRemove, pluginsToInstall, pluginsToUpgrade));
+
+        inOrder.verify(removeAction).setPurge(true);
+        inOrder.verify(removeAction).execute(pluginsToRemove);
+
+        inOrder.verify(installAction).execute(pluginsToInstall);
+
+        inOrder.verify(removeAction).setPurge(false);
+        inOrder.verify(removeAction).execute(pluginsToUpgrade);
+        inOrder.verify(installAction).execute(pluginsToUpgrade);
+    }
+
+    private void createPlugin(String name) throws IOException {
+        createPlugin(name, Version.CURRENT);
+    }
+
+    private void createPlugin(String name, Version version) throws IOException {
+        PluginTestUtil.writePluginProperties(
+            env.pluginsFile().resolve(name),
+            "description",
+            "dummy",
+            "name",
+            name,
+            "version",
+            "1.0",
+            "elasticsearch.version",
+            version.toString(),
+            "java.version",
+            System.getProperty("java.specification.version"),
+            "classname",
+            "SomeClass"
+        );
+    }
+}

+ 12 - 2
libs/cli/src/main/java/org/elasticsearch/cli/Terminal.java

@@ -27,7 +27,7 @@ import java.util.Locale;
  * The available methods are similar to those of {@link Console}, with the ability
  * to read either normal text or a password, and the ability to print a line
  * of text. Printing is also gated by the {@link Verbosity} of the terminal,
- * which allows {@link #println(Verbosity,String)} calls which act like a logger,
+ * which allows {@link #println(Verbosity,CharSequence)} calls which act like a logger,
  * only actually printing if the verbosity level of the terminal is above
  * the verbosity of the message.
 */
@@ -113,7 +113,7 @@ public abstract class Terminal {
     }
 
     /** Prints message to the terminal at {@code verbosity} level, without a newline. */
-    private void print(Verbosity verbosity, String msg, boolean isError) {
+    protected void print(Verbosity verbosity, String msg, boolean isError) {
         if (isPrintable(verbosity)) {
             PrintWriter writer = isError ? getErrorWriter() : getWriter();
             writer.print(msg);
@@ -206,6 +206,16 @@ public abstract class Terminal {
         this.getErrorWriter().flush();
     }
 
+    /**
+     * Indicates whether this terminal is for a headless system i.e. is not interactive. If an instances answers
+     * {@code false}, interactive operations can be attempted, but it is not guaranteed that they will succeed.
+     *
+     * @return if this terminal is headless.
+     */
+    public boolean isHeadless() {
+        return false;
+    }
+
     private static class ConsoleTerminal extends Terminal {
 
         private static final Console CONSOLE = System.console();

+ 183 - 10
qa/os/src/test/java/org/elasticsearch/packaging/test/DockerTests.java

@@ -8,6 +8,7 @@
 
 package org.elasticsearch.packaging.test;
 
+import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 
@@ -19,6 +20,7 @@ import org.elasticsearch.packaging.util.ServerUtils;
 import org.elasticsearch.packaging.util.Shell;
 import org.elasticsearch.packaging.util.Shell.Result;
 import org.elasticsearch.packaging.util.docker.DockerRun;
+import org.elasticsearch.packaging.util.docker.MockServer;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.BeforeClass;
@@ -32,6 +34,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.StringJoiner;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
@@ -46,6 +49,7 @@ import static org.elasticsearch.packaging.util.FileMatcher.p750;
 import static org.elasticsearch.packaging.util.FileMatcher.p755;
 import static org.elasticsearch.packaging.util.FileMatcher.p775;
 import static org.elasticsearch.packaging.util.FileUtils.append;
+import static org.elasticsearch.packaging.util.FileUtils.deleteIfExists;
 import static org.elasticsearch.packaging.util.FileUtils.rm;
 import static org.elasticsearch.packaging.util.docker.Docker.chownWithPrivilegeEscalation;
 import static org.elasticsearch.packaging.util.docker.Docker.copyFromContainer;
@@ -69,11 +73,14 @@ import static org.elasticsearch.packaging.util.docker.DockerRun.builder;
 import static org.hamcrest.Matchers.allOf;
 import static org.hamcrest.Matchers.arrayContaining;
 import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.emptyString;
 import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasEntry;
 import static org.hamcrest.Matchers.hasItems;
 import static org.hamcrest.Matchers.hasKey;
+import static org.hamcrest.Matchers.hasSize;
 import static org.hamcrest.Matchers.is;
 import static org.hamcrest.Matchers.matchesPattern;
 import static org.hamcrest.Matchers.not;
@@ -92,10 +99,14 @@ import static org.junit.Assume.assumeTrue;
  *     <li>Images for Cloud</li>
  * </ul>
  */
+@ThreadLeakFilters(defaultFilters = true, filters = { HttpClientThreadsFilter.class })
 public class DockerTests extends PackagingTestCase {
     private Path tempDir;
     private static final String PASSWORD = "nothunter2";
 
+    private static final String EXAMPLE_PLUGIN_SYSPROP = "tests.example-plugin";
+    private static final String EXAMPLE_PLUGIN_PATH = System.getProperty(EXAMPLE_PLUGIN_SYSPROP);
+
     @BeforeClass
     public static void filterDistros() {
         assumeTrue("only Docker", distribution().isDocker());
@@ -159,7 +170,7 @@ public class DockerTests extends PackagingTestCase {
     /**
      * Check that Cloud images bundle a selection of plugins.
      */
-    public void test021PluginsListWithPlugins() {
+    public void test021PluginsListWithDefaultCloudPlugins() {
         assumeTrue(
             "Only applies to Cloud images",
             distribution.packaging == Packaging.DOCKER_CLOUD || distribution().packaging == Packaging.DOCKER_CLOUD_ESS
@@ -176,30 +187,187 @@ public class DockerTests extends PackagingTestCase {
     }
 
     /**
-     * Checks that ESS images can install plugins from the local archive.
+     * Check that a plugin can be installed without special permissions.
      */
-    public void test022InstallPluginsFromLocalArchive() {
-        assumeTrue("Only applies to ESS images", distribution().packaging == Packaging.DOCKER_CLOUD_ESS);
+    public void test022InstallPlugin() {
+        runContainer(
+            distribution(),
+            builder().envVar("ELASTIC_PASSWORD", PASSWORD).volume(Path.of(EXAMPLE_PLUGIN_PATH), "/analysis-icu.zip")
+        );
 
         final String plugin = "analysis-icu";
+        assertThat("Expected " + plugin + " to not be installed", listPlugins(), not(hasItems(plugin)));
 
         final Installation.Executables bin = installation.executables();
-        List<String> plugins = sh.run(bin.pluginTool + " list").stdout.lines().collect(Collectors.toList());
+        sh.run(bin.pluginTool + " install file:///analysis-icu.zip");
+
+        final boolean isCloudImage = distribution().packaging == Packaging.DOCKER_CLOUD
+            || distribution().packaging == Packaging.DOCKER_CLOUD_ESS;
+
+        final List<String> expectedPlugins = isCloudImage
+            ? List.of("repository-azure", "repository-gcs", "repository-s3", "analysis-icu")
+            : List.of("analysis-icu");
 
-        assertThat("Expected " + plugin + " to not be installed", plugins, not(hasItems(plugin)));
+        assertThat("Expected installed plugins to be listed", listPlugins(), equalTo(expectedPlugins));
+    }
+
+    /**
+     * Checks that ESS images can install plugins from the local archive.
+     */
+    public void test023InstallPluginsFromLocalArchive() {
+        assumeTrue("Only ESS images have a local archive", distribution().packaging == Packaging.DOCKER_CLOUD_ESS);
+
+        final String plugin = "analysis-icu";
+        final Installation.Executables bin = installation.executables();
+
+        assertThat("Expected " + plugin + " to not be installed", listPlugins(), not(hasItems(plugin)));
 
         // Stuff the proxy settings with garbage, so any attempt to go out to the internet would fail
         sh.getEnv()
             .put("ES_JAVA_OPTS", "-Dhttp.proxyHost=example.org -Dhttp.proxyPort=9999 -Dhttps.proxyHost=example.org -Dhttps.proxyPort=9999");
-        sh.run("/opt/plugins/plugin-wrapper.sh install --batch analysis-icu");
+        sh.run(bin.pluginTool + " install --batch analysis-icu");
+
+        assertThat("Expected " + plugin + " to be installed", listPlugins(), hasItems(plugin));
+    }
 
-        plugins = sh.run(bin.pluginTool + " list").stdout.lines().collect(Collectors.toList());
+    /**
+     * Checks that plugins can be installed by deploying a plugins config file.
+     */
+    public void test024InstallPluginUsingConfigFile() {
+        final boolean isCloudImage = distribution().packaging == Packaging.DOCKER_CLOUD
+            || distribution().packaging == Packaging.DOCKER_CLOUD_ESS;
+
+        final StringJoiner pluginsDescriptor = new StringJoiner("\n", "", "\n");
+        pluginsDescriptor.add("plugins:");
+        pluginsDescriptor.add("  - id: analysis-icu");
+        pluginsDescriptor.add("    location: file:///analysis-icu.zip");
+        if (isCloudImage) {
+            // The repository plugins have to be present, because (1) they are preinstalled, and (2) they
+            // are owned by `root` and can't be removed.
+            Stream.of("repository-s3", "repository-azure", "repository-gcs").forEach(plugin -> pluginsDescriptor.add("  - id: " + plugin));
+        }
+
+        final String filename = "elasticsearch-plugins.yml";
+        append(tempDir.resolve(filename), pluginsDescriptor.toString());
+
+        // Restart the container. This will sync the plugins automatically. Also
+        // stuff the proxy settings with garbage, so any attempt to go out to the internet would fail. The
+        // command should instead use the bundled plugin archive.
+        runContainer(
+            distribution(),
+            builder().volume(tempDir.resolve(filename), installation.config.resolve(filename))
+                .volume(Path.of(EXAMPLE_PLUGIN_PATH), "/analysis-icu.zip")
+                .envVar("ELASTIC_PASSWORD", PASSWORD)
+                .envVar(
+                    "ES_JAVA_OPTS",
+                    "-Dhttp.proxyHost=example.org -Dhttp.proxyPort=9999 -Dhttps.proxyHost=example.org -Dhttps.proxyPort=9999"
+                )
+        );
 
-        assertThat("Expected " + plugin + " to be installed", plugins, hasItems(plugin));
+        // Since ES is doing the installing, give it a chance to complete
+        waitForElasticsearch(installation, "elastic", PASSWORD);
+
+        assertThat("List of installed plugins is incorrect", listPlugins(), hasItems("analysis-icu"));
+    }
+
+    /**
+     * Checks that ESS images can manage plugins from the local archive by deploying a plugins config file.
+     */
+    public void test025InstallPluginFromArchiveUsingConfigFile() {
+        assumeTrue("Only ESS image has a plugin archive", distribution().packaging == Packaging.DOCKER_CLOUD_ESS);
+
+        // The repository plugins have to be present, because (1) they are preinstalled, and (2) they
+        // are owned by `root` and can't be removed.
+        final String[] plugins = { "repository-s3", "repository-azure", "repository-gcs", "analysis-icu", "analysis-phonetic" };
+
+        final StringJoiner pluginsDescriptor = new StringJoiner("\n", "", "\n");
+        pluginsDescriptor.add("plugins:");
+        for (String plugin : plugins) {
+            pluginsDescriptor.add("  - id: " + plugin);
+        }
+
+        final String filename = "elasticsearch-plugins.yml";
+        append(tempDir.resolve(filename), pluginsDescriptor.toString());
+
+        // Restart the container. This will sync the plugins automatically. Also
+        // stuff the proxy settings with garbage, so any attempt to go out to the internet would fail. The
+        // command should instead use the bundled plugin archive.
+        runContainer(
+            distribution(),
+            builder().volume(tempDir.resolve(filename), installation.config.resolve(filename))
+                .envVar("ELASTIC_PASSWORD", PASSWORD)
+                .envVar(
+                    "ES_JAVA_OPTS",
+                    "-Dhttp.proxyHost=example.org -Dhttp.proxyPort=9999 -Dhttps.proxyHost=example.org -Dhttps.proxyPort=9999"
+                )
+        );
+
+        // Since ES is doing the installing, give it a chance to complete
+        waitForElasticsearch(installation, "elastic", PASSWORD);
+
+        assertThat("List of installed plugins is incorrect", listPlugins(), containsInAnyOrder(plugins));
     }
 
     /**
-     * Check that the JDK's cacerts file is a symlink to the copy provided by the operating system.
+     * Check that when using Elasticsearch's plugins sync capability, it will use a proxy when configured to do so.
+     * This could either be in the plugins config file, or via the standard Java system properties.
+     */
+    public void test024SyncPluginsUsingProxy() {
+        MockServer.withMockServer(mockServer -> {
+            for (boolean useConfigFile : List.of(true, false)) {
+                mockServer.clearExpectations();
+
+                final StringJoiner config = new StringJoiner("\n", "", "\n");
+                config.add("plugins:");
+                // The repository plugins have to be present for Cloud images, because (1) they are preinstalled, and (2) they
+                // are owned by `root` and can't be removed.
+                if (distribution().packaging == Packaging.DOCKER_CLOUD || distribution().packaging == Packaging.DOCKER_CLOUD_ESS) {
+                    for (String plugin : List.of("repository-s3", "repository-azure", "repository-gcs", "analysis-icu")) {
+                        config.add("  - id: " + plugin);
+                    }
+                }
+                // This is the new plugin to install. We don't use an official plugin because then Elasticsearch
+                // will attempt an SSL connection and that just makes everything more complicated.
+                config.add("  - id: my-plugin");
+                config.add("    location: http://example.com/my-plugin.zip");
+
+                if (useConfigFile) {
+                    config.add("proxy: mockserver:" + mockServer.getPort());
+                }
+
+                final String filename = "elasticsearch-plugins.yml";
+                final Path pluginsConfigPath = tempDir.resolve(filename);
+                deleteIfExists(pluginsConfigPath);
+                append(pluginsConfigPath, config.toString());
+
+                final DockerRun builder = builder().volume(pluginsConfigPath, installation.config.resolve(filename))
+                    .extraArgs("--link " + mockServer.getContainerId() + ":mockserver");
+
+                if (useConfigFile == false) {
+                    builder.envVar("ES_JAVA_OPTS", "-Dhttp.proxyHost=mockserver -Dhttp.proxyPort=" + mockServer.getPort());
+                }
+
+                // Restart the container. This will sync plugins automatically, which will fail because
+                // ES will be unable to install `my-plugin`
+                final Result result = runContainerExpectingFailure(distribution(), builder);
+
+                final List<Map<String, String>> interactions = mockServer.getInteractions();
+
+                assertThat(result.stderr, containsString("FileNotFoundException: http://example.com/my-plugin.zip"));
+
+                // Now check that Elasticsearch did use the proxy server
+                assertThat(interactions, hasSize(1));
+                final Map<String, String> interaction = interactions.get(0);
+                assertThat(interaction, hasEntry("httpRequest.headers.Host[0]", "example.com"));
+                assertThat(interaction, hasEntry("httpRequest.headers.User-Agent[0]", "elasticsearch-plugin-installer"));
+                assertThat(interaction, hasEntry("httpRequest.method", "GET"));
+                assertThat(interaction, hasEntry("httpRequest.path", "/my-plugin.zip"));
+            }
+        });
+    }
+
+    /**
+     * Check that the JDK's `cacerts` file is a symlink to the copy provided by the operating system.
      */
     public void test040JavaUsesTheOsProvidedKeystore() {
         final String path = sh.run("realpath jdk/lib/security/cacerts").stdout;
@@ -1048,4 +1216,9 @@ public class DockerTests extends PackagingTestCase {
             assertThat(Path.of("/opt/" + beat + "/modules.d"), file(Directory, "root", "root", p755));
         });
     }
+
+    private List<String> listPlugins() {
+        final Installation.Executables bin = installation.executables();
+        return sh.run(bin.pluginTool + " list").stdout.lines().collect(Collectors.toList());
+    }
 }

+ 22 - 0
qa/os/src/test/java/org/elasticsearch/packaging/test/HttpClientThreadsFilter.java

@@ -0,0 +1,22 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.packaging.test;
+
+import com.carrotsearch.randomizedtesting.ThreadFilter;
+
+/**
+ * Java's {@link java.net.http.HttpClient} spawns threads, which causes our thread leak
+ * detection to fail. Filter these threads out since AFAICT we can't completely clean them up.
+ */
+public class HttpClientThreadsFilter implements ThreadFilter {
+    @Override
+    public boolean reject(Thread t) {
+        return t.getName().startsWith("HttpClient");
+    }
+}

+ 49 - 8
qa/os/src/test/java/org/elasticsearch/packaging/test/PluginCliTests.java

@@ -8,22 +8,25 @@
 
 package org.elasticsearch.packaging.test;
 
-import org.elasticsearch.packaging.test.PackagingTestCase.AwaitsFix;
+import org.elasticsearch.packaging.util.FileUtils;
 import org.elasticsearch.packaging.util.Installation;
 import org.elasticsearch.packaging.util.Platforms;
 import org.elasticsearch.packaging.util.Shell;
 import org.junit.Before;
 
+import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.matchesPattern;
 import static org.junit.Assume.assumeFalse;
 import static org.junit.Assume.assumeTrue;
 
-@AwaitsFix(bugUrl = "Needs to be re-enabled")
+@PackagingTestCase.AwaitsFix(bugUrl = "Needs to be re-enabled")
 public class PluginCliTests extends PackagingTestCase {
 
     private static final String EXAMPLE_PLUGIN_NAME = "custom-settings";
@@ -31,7 +34,7 @@ public class PluginCliTests extends PackagingTestCase {
 
     static {
         // re-read before each test so the plugin path can be manipulated within tests
-        EXAMPLE_PLUGIN_ZIP = Paths.get(System.getProperty("tests.example-plugin", "/dummy/path"));
+        EXAMPLE_PLUGIN_ZIP = Paths.get(System.getProperty("tests.example-plugin"));
     }
 
     @Before
@@ -46,7 +49,7 @@ public class PluginCliTests extends PackagingTestCase {
 
     private Shell.Result assertWithPlugin(Installation.Executable pluginTool, Path pluginZip, String pluginName, PluginAction action)
         throws Exception {
-        Shell.Result installResult = pluginTool.run("install --batch \"" + pluginZip.toUri().toString() + "\"");
+        Shell.Result installResult = pluginTool.run("install --batch \"" + pluginZip.toUri() + "\"");
         action.run(installResult);
         return pluginTool.run("remove " + pluginName);
     }
@@ -70,15 +73,13 @@ public class PluginCliTests extends PackagingTestCase {
         Platforms.onLinux(() -> sh.run("chown elasticsearch:elasticsearch " + linkedPlugins.toString()));
         Files.createSymbolicLink(pluginsDir, linkedPlugins);
         // Packaged installation don't get autoconfigured yet
-        // TODO: Remove this in https://github.com/elastic/elasticsearch/pull/75144
-        String protocol = distribution.isPackage() ? "http" : "https";
         assertWithExamplePlugin(installResult -> {
             assertWhileRunning(() -> {
-                final String pluginsResponse = makeRequest(protocol + "://localhost:9200/_cat/plugins?h=component").strip();
+                final String pluginsResponse = makeRequest("https://localhost:9200/_cat/plugins?h=component").strip();
                 assertThat(pluginsResponse, equalTo(EXAMPLE_PLUGIN_NAME));
 
                 String settingsPath = "_cluster/settings?include_defaults&filter_path=defaults.custom.simple";
-                final String settingsResponse = makeRequest(protocol + "://localhost:9200/" + settingsPath).strip();
+                final String settingsResponse = makeRequest("https://localhost:9200/" + settingsPath).strip();
                 assertThat(settingsResponse, equalTo("{\"defaults\":{\"custom\":{\"simple\":\"foo\"}}}"));
             });
         });
@@ -119,4 +120,44 @@ public class PluginCliTests extends PackagingTestCase {
         sh.setUmask("0077");
         assertWithExamplePlugin(installResult -> {});
     }
+
+    /**
+     * Check that the `install` subcommand cannot be used if a plugins config file exists.
+     */
+    public void test30InstallFailsIfConfigFilePresent() throws IOException {
+        Files.writeString(installation.config.resolve("elasticsearch-plugins.yml"), "");
+
+        Shell.Result result = installation.executables().pluginTool.run("install analysis-icu", null, true);
+        assertThat(result.isSuccess(), is(false));
+        assertThat(result.stderr, matchesPattern("^Plugins config \\[[^+]] exists.*"));
+    }
+
+    /**
+     * Check that the `remove` subcommand cannot be used if a plugins config file exists.
+     */
+    public void test31RemoveFailsIfConfigFilePresent() throws IOException {
+        Files.writeString(installation.config.resolve("elasticsearch-plugins.yml"), "");
+
+        Shell.Result result = installation.executables().pluginTool.run("install analysis-icu", null, true);
+        assertThat(result.isSuccess(), is(false));
+        assertThat(result.stderr, matchesPattern("^Plugins config \\[[^+]] exists.*"));
+    }
+
+    /**
+     * Check that when a plugins config file exists, Elasticsearch refuses to start up, since using
+     * a config file is only supported in Docker.
+     */
+    public void test32FailsToStartWhenPluginsConfigExists() throws Exception {
+        try {
+            Files.writeString(installation.config("elasticsearch-plugins.yml"), "content doesn't matter for this test");
+            Shell.Result result = runElasticsearchStartCommand(null, false, true);
+            assertThat(result.isSuccess(), equalTo(false));
+            assertThat(
+                result.stderr,
+                containsString("Can only use [elasticsearch-plugins.yml] config file with distribution type [docker]")
+            );
+        } finally {
+            FileUtils.rm(installation.config("elasticsearch-plugins.yml"));
+        }
+    }
 }

+ 1 - 3
qa/os/src/test/java/org/elasticsearch/packaging/util/docker/Docker.java

@@ -195,7 +195,7 @@ public class Docker {
         do {
             try {
                 // Give the container a chance to exit out
-                Thread.sleep(1000);
+                Thread.sleep(2000);
 
                 if (sh.run("docker ps --quiet --no-trunc").stdout.contains(containerId) == false) {
                     isElasticsearchRunning = false;
@@ -462,8 +462,6 @@ public class Docker {
     }
 
     private static void verifyCloudContainerInstallation(Installation es) {
-        assertThat(Path.of("/opt/plugins/plugin-wrapper.sh"), file("root", "root", p555));
-
         final String pluginArchive = "/opt/plugins/archive";
         final List<String> plugins = listContents(pluginArchive);
 

+ 5 - 0
qa/os/src/test/java/org/elasticsearch/packaging/util/docker/DockerRun.java

@@ -11,6 +11,7 @@ package org.elasticsearch.packaging.util.docker;
 import org.elasticsearch.packaging.util.Distribution;
 import org.elasticsearch.packaging.util.Platforms;
 
+import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -53,6 +54,10 @@ public class DockerRun {
     }
 
     public DockerRun volume(Path from, String to) {
+        requireNonNull(from);
+        if (Files.exists(from) == false) {
+            throw new RuntimeException("Path [" + from + "] does not exist");
+        }
         this.volumes.put(requireNonNull(from), Path.of(requireNonNull(to)));
         return this;
     }

+ 201 - 0
qa/os/src/test/java/org/elasticsearch/packaging/util/docker/MockServer.java

@@ -0,0 +1,201 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.packaging.util.docker;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.ValueNode;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.elasticsearch.core.CheckedConsumer;
+import org.elasticsearch.packaging.test.PackagingTestCase;
+import org.elasticsearch.packaging.util.Shell;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpRequest.BodyPublishers;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import static org.hamcrest.Matchers.is;
+import static org.junit.Assert.assertThat;
+
+/**
+ * Providers an interface to <a href="https://org.mock-server.com/">Mockserver</a>, where a proxy
+ * server is needed for testing in Docker tests.
+ * <p>
+ * To use the server, link the container under test with the mockserver using the <code>--link</code>
+ * CLI option, using the {@link #getContainerId()} option. By aliasing the ID, you will know what
+ * hostname to use to connect to the proxy. For example:
+ *
+ * <pre>"--link " + mockserver.getContainerId() + ":mockserver"</pre>
+ *
+ * <p>All requests will result in a 404, but those requests are recorded and can be retried with
+ * {@link #getInteractions()}. These can can be reset with {@link #clearExpectations()}.
+ */
+public class MockServer {
+    protected final Logger logger = LogManager.getLogger(getClass());
+
+    private static final int CONTAINER_PORT = 1080; // default for image
+
+    private final Shell shell;
+    private final HttpClient client;
+    private ExecutorService executorService;
+    private String containerId;
+
+    /**
+     * Create a new mockserver, and execute the supplied {@code runnable}. The mockserver will
+     * be cleaned up afterwards.
+     * @param runnable the code to run e.g. the test case
+     */
+    public static void withMockServer(CheckedConsumer<MockServer, Exception> runnable) {
+        final MockServer mockServer = new MockServer();
+        try {
+            mockServer.start();
+            runnable.accept(mockServer);
+            mockServer.close();
+        } catch (Throwable e) {
+            mockServer.close();
+        }
+    }
+
+    private MockServer() {
+        this.shell = new Shell();
+        this.executorService = Executors.newSingleThreadExecutor();
+        this.client = HttpClient.newBuilder().executor(executorService).build();
+    }
+
+    private void start() throws Exception {
+        final String command = "docker run -t --detach --rm -p " + CONTAINER_PORT + ":" + CONTAINER_PORT + " mockserver/mockserver:latest";
+        this.containerId = this.shell.run(command).stdout.trim();
+
+        // It's a Java app, so give it a chance to wake up. I'd add a healthcheck to the above command,
+        // but the image doesn't have any CLI utils at all.
+        PackagingTestCase.assertBusy(() -> {
+            try {
+                this.reset();
+            } catch (Exception e) {
+                // Only assertions are retried.
+                throw new AssertionError(e);
+            }
+        }, 20, TimeUnit.SECONDS);
+    }
+
+    public void clearExpectations() throws Exception {
+        doRequest("http://localhost:" + CONTAINER_PORT + "/mockserver/clear?type=EXPECTATIONS", "{ \"path\": \"/*\" }");
+    }
+
+    public void reset() throws Exception {
+        doRequest("http://localhost:" + CONTAINER_PORT + "/mockserver/reset", null);
+    }
+
+    /**
+     * Returns all interactions with the mockserver since startup, the last call to {@link #reset()} or the
+     * last call to {@link #clearExpectations()}. The JSON returned by the mockserver is flattened, so that
+     * the period-seperated keys in each map represent the structure of the JSON.
+     *
+     * @return a list of interactions
+     * @throws Exception if anything goes wrong
+     */
+    public List<Map<String, String>> getInteractions() throws Exception {
+        final String url = "http://localhost:" + CONTAINER_PORT + "/mockserver/retrieve?type=REQUEST_RESPONSES";
+
+        final String result = doRequest(url, null);
+
+        final ObjectMapper objectMapper = new ObjectMapper();
+        final JsonNode jsonNode = objectMapper.readTree(result);
+
+        assertThat("Response from mockserver is not a JSON array", jsonNode.isArray(), is(true));
+
+        final List<Map<String, String>> interactions = new ArrayList<>();
+
+        for (JsonNode node : jsonNode) {
+            final Map<String, String> interaction = new HashMap<>();
+            addKeys("", node, interaction);
+            interactions.add(interaction);
+        }
+
+        return interactions;
+    }
+
+    private void close() {
+        if (this.containerId != null) {
+            this.shell.run("docker rm -f " + this.containerId);
+            this.containerId = null;
+        }
+
+        if (this.executorService != null) {
+            this.executorService.shutdown();
+            this.executorService = null;
+        }
+    }
+
+    public String getContainerId() {
+        return containerId;
+    }
+
+    public int getPort() {
+        return CONTAINER_PORT;
+    }
+
+    /**
+     * Recursively flattens a JsonNode into a map, to make it easier to pick out entries and make assertions.
+     * Keys are concatenated with periods.
+     *
+     * @param currentPath used recursively to construct the key
+     * @param jsonNode the current node to flatten
+     * @param map entries are added into this map
+     */
+    private void addKeys(String currentPath, JsonNode jsonNode, Map<String, String> map) {
+        if (jsonNode.isObject()) {
+            ObjectNode objectNode = (ObjectNode) jsonNode;
+            Iterator<Map.Entry<String, JsonNode>> iter = objectNode.fields();
+            String pathPrefix = currentPath.isEmpty() ? "" : currentPath + ".";
+
+            while (iter.hasNext()) {
+                Map.Entry<String, JsonNode> entry = iter.next();
+                addKeys(pathPrefix + entry.getKey(), entry.getValue(), map);
+            }
+        } else if (jsonNode.isArray()) {
+            ArrayNode arrayNode = (ArrayNode) jsonNode;
+            for (int i = 0; i < arrayNode.size(); i++) {
+                addKeys(currentPath + "[" + i + "]", arrayNode.get(i), map);
+            }
+        } else if (jsonNode.isValueNode()) {
+            ValueNode valueNode = (ValueNode) jsonNode;
+            map.put(currentPath, valueNode.asText());
+        }
+    }
+
+    private String doRequest(String urlString, String body) throws Exception {
+        final HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(urlString));
+
+        if (body == null) {
+            request.method("PUT", BodyPublishers.noBody());
+        } else {
+            request.method("PUT", BodyPublishers.ofString(body)).header("Content-Type", "application/json");
+        }
+
+        final HttpResponse<String> response = client.send(request.build(), BodyHandlers.ofString());
+
+        return response.body();
+    }
+}

+ 16 - 0
server/src/main/java/org/elasticsearch/bootstrap/Bootstrap.java

@@ -16,8 +16,10 @@ import org.apache.logging.log4j.core.appender.ConsoleAppender;
 import org.apache.logging.log4j.core.config.Configurator;
 import org.apache.lucene.util.Constants;
 import org.apache.lucene.util.StringHelper;
+import org.elasticsearch.Build;
 import org.elasticsearch.ElasticsearchException;
 import org.elasticsearch.Version;
+import org.elasticsearch.bootstrap.plugins.PluginsManager;
 import org.elasticsearch.cli.UserException;
 import org.elasticsearch.common.PidFile;
 import org.elasticsearch.common.filesystem.FileSystemNatives;
@@ -339,6 +341,20 @@ final class Bootstrap {
             // setDefaultUncaughtExceptionHandler
             Thread.setDefaultUncaughtExceptionHandler(new ElasticsearchUncaughtExceptionHandler());
 
+            if (PluginsManager.configExists(environment)) {
+                if (Build.CURRENT.type() == Build.Type.DOCKER) {
+                    try {
+                        PluginsManager.syncPlugins(environment);
+                    } catch (Exception e) {
+                        throw new BootstrapException(e);
+                    }
+                } else {
+                    throw new BootstrapException(
+                        new ElasticsearchException("Can only use [elasticsearch-plugins.yml] config file with distribution type [docker]")
+                    );
+                }
+            }
+
             INSTANCE.setup(true, environment);
 
             try {

+ 94 - 0
server/src/main/java/org/elasticsearch/bootstrap/plugins/LoggerTerminal.java

@@ -0,0 +1,94 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.bootstrap.plugins;
+
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.logging.log4j.spi.AbstractLogger;
+import org.apache.logging.log4j.spi.ExtendedLoggerWrapper;
+import org.elasticsearch.cli.Terminal;
+
+import java.io.OutputStream;
+import java.io.PrintWriter;
+
+public final class LoggerTerminal extends Terminal {
+    private final ExtendedLoggerWrapper logger;
+
+    private static final String FQCN = LoggerTerminal.class.getName();
+
+    private LoggerTerminal(final Logger logger) {
+        super(System.lineSeparator());
+        this.logger = new ExtendedLoggerWrapper((AbstractLogger) logger, logger.getName(), logger.getMessageFactory());
+    }
+
+    public static LoggerTerminal getLogger(String logger) {
+        return new LoggerTerminal(LogManager.getLogger(logger));
+    }
+
+    @Override
+    public boolean isHeadless() {
+        return true;
+    }
+
+    @Override
+    public String readText(String prompt) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public char[] readSecret(String prompt) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public char[] readSecret(String prompt, int maxLength) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public PrintWriter getWriter() {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public OutputStream getOutputStream() {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public PrintWriter getErrorWriter() {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    protected void print(Verbosity verbosity, String msg, boolean isError) {
+        Level level;
+        switch (verbosity) {
+            case SILENT:
+                level = isError ? Level.ERROR : Level.WARN;
+                break;
+
+            case VERBOSE:
+                level = Level.DEBUG;
+                break;
+
+            case NORMAL:
+            default:
+                level = isError ? Level.WARN : Level.INFO;
+                break;
+        }
+        this.logger.logIfEnabled(FQCN, level, null, msg.trim(), (Throwable) null);
+    }
+
+    @Override
+    public void flush() {
+        throw new UnsupportedOperationException();
+    }
+}

+ 75 - 0
server/src/main/java/org/elasticsearch/bootstrap/plugins/PluginsManager.java

@@ -0,0 +1,75 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.bootstrap.plugins;
+
+import org.elasticsearch.cli.Terminal;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.plugins.PluginsSynchronizer;
+
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * This class is responsible for adding, updating or removing plugins so that the list of installed plugins
+ * matches those in the {@code elasticsearch-plugins.yml} config file. It does this by loading a class
+ * dynamically from the {@code plugin-cli} jar and executing it.
+ */
+public class PluginsManager {
+
+    public static final String SYNC_PLUGINS_ACTION = "org.elasticsearch.plugins.cli.SyncPluginsAction";
+
+    public static boolean configExists(Environment env) {
+        return Files.exists(env.configFile().resolve("elasticsearch-plugins.yml"));
+    }
+
+    /**
+     * Synchronizes the currently-installed plugins.
+     * @param env the environment to use
+     * @throws Exception if anything goes wrong
+     */
+    public static void syncPlugins(Environment env) throws Exception {
+        ClassLoader classLoader = buildClassLoader(env);
+
+        @SuppressWarnings("unchecked")
+        final Class<PluginsSynchronizer> synchronizerClass = (Class<PluginsSynchronizer>) classLoader.loadClass(SYNC_PLUGINS_ACTION);
+
+        final PluginsSynchronizer provider = synchronizerClass.getConstructor(Terminal.class, Environment.class)
+            .newInstance(LoggerTerminal.getLogger(SYNC_PLUGINS_ACTION), env);
+
+        provider.execute();
+    }
+
+    private static ClassLoader buildClassLoader(Environment env) {
+        final Path pluginLibDir = env.libFile().resolve("tools").resolve("plugin-cli");
+
+        try {
+            final URL[] urls = Files.list(pluginLibDir)
+                .filter(each -> each.getFileName().toString().endsWith(".jar"))
+                .map(PluginsManager::pathToURL)
+                .toArray(URL[]::new);
+
+            return URLClassLoader.newInstance(urls, PluginsManager.class.getClassLoader());
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to list jars in [" + pluginLibDir + "]: " + e.getMessage(), e);
+        }
+    }
+
+    private static URL pathToURL(Path path) {
+        try {
+            return path.toUri().toURL();
+        } catch (MalformedURLException e) {
+            // Shouldn't happen, but have to handle the exception
+            throw new RuntimeException("Failed to convert path [" + path + "] to URL", e);
+        }
+    }
+}

+ 5 - 2
server/src/main/java/org/elasticsearch/plugins/PluginsService.java

@@ -322,10 +322,13 @@ public class PluginsService implements ReportingService<PluginsAndModules> {
         if (Files.exists(rootPath)) {
             try (DirectoryStream<Path> stream = Files.newDirectoryStream(rootPath)) {
                 for (Path plugin : stream) {
-                    if (FileSystemUtils.isDesktopServicesStore(plugin) || plugin.getFileName().toString().startsWith(".removing-")) {
+                    final String filename = plugin.getFileName().toString();
+                    if (FileSystemUtils.isDesktopServicesStore(plugin)
+                        || filename.startsWith(".removing-")
+                        || filename.equals(".elasticsearch-plugins.yml.cache")) {
                         continue;
                     }
-                    if (seen.add(plugin.getFileName().toString()) == false) {
+                    if (seen.add(filename) == false) {
                         throw new IllegalStateException("duplicate plugin: " + plugin);
                     }
                     plugins.add(plugin);

+ 17 - 0
server/src/main/java/org/elasticsearch/plugins/PluginsSynchronizer.java

@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugins;
+
+/**
+ * This is a marker interface for classes that are capable of synchronizing the currently-installed ES plugins
+ * with those that ought to be installed according to a configuration file.
+ */
+public interface PluginsSynchronizer {
+    void execute() throws Exception;
+}