Allow to install custom Gerrit-plugins in gerrit-master chart

It was not yet possible to install plugins that were not packaged plugins in
the gerrit-master chart.

This change allows to configure a list of plugins to install for Gerrit.
To do this, it is required to give a URL, where the plugin jar can be
downloaded. Additionally, a SHA1-checksum has to be provided to ensure that
the same jar is used for each pod. The plugins are downloaded in the
`gerrit-init` init-container before Gerrit starts up, as long as the jar
is not yet present in the plugins directory. Optionally, the plugin
files can be cached.

Change-Id: I34e6bc5668090b37c9749d4df72eeb719fdf3807
diff --git a/container-images/gerrit-init/README.md b/container-images/gerrit-init/README.md
index 5266dbb..4cf03f7 100644
--- a/container-images/gerrit-init/README.md
+++ b/container-images/gerrit-init/README.md
@@ -17,6 +17,14 @@
 
 * start the container via start script `/var/tools/gerrit_init.py`
 
+The `download_plugins.py`-script
+
+* parses required plugins from config file
+* removes unwanted plugins
+* installs and updates plugins not packaged in Gerrit's war-file
+* plugin files are validated using SHA1
+* plugin files may optionally be cached
+
 The `gerrit_init.py`-script
 
 * reads configuration from gerrit.config (via `gerrit_config_parser.py`)
diff --git a/container-images/gerrit-init/config/default.config.yaml b/container-images/gerrit-init/config/default.config.yaml
index e69de29..65b7b28 100644
--- a/container-images/gerrit-init/config/default.config.yaml
+++ b/container-images/gerrit-init/config/default.config.yaml
@@ -0,0 +1 @@
+pluginCache: false
diff --git a/container-images/gerrit-init/dependencies/Pipfile b/container-images/gerrit-init/dependencies/Pipfile
index 32dcbb1..166e043 100644
--- a/container-images/gerrit-init/dependencies/Pipfile
+++ b/container-images/gerrit-init/dependencies/Pipfile
@@ -7,6 +7,7 @@
 
 [packages]
 pyyaml = "~=5.1.1"
+requests = "~=2.21.0"
 
 [requires]
 python_version = "3.7"
diff --git a/container-images/gerrit-init/dependencies/Pipfile.lock b/container-images/gerrit-init/dependencies/Pipfile.lock
index 4196274..8c818e8 100644
--- a/container-images/gerrit-init/dependencies/Pipfile.lock
+++ b/container-images/gerrit-init/dependencies/Pipfile.lock
@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "4c5889e94a1b967f4cf238d7c5265e62e03611deb65b66c4a77f1673505436d7"
+            "sha256": "f8c5bd58b89445bd252681282fb1144e20c6b22274e58b2a5221af137273c832"
         },
         "pipfile-spec": 6,
         "requires": {
@@ -16,6 +16,27 @@
         ]
     },
     "default": {
+        "certifi": {
+            "hashes": [
+                "sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939",
+                "sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695"
+            ],
+            "version": "==2019.6.16"
+        },
+        "chardet": {
+            "hashes": [
+                "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
+                "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
+            ],
+            "version": "==3.0.4"
+        },
+        "idna": {
+            "hashes": [
+                "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
+                "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
+            ],
+            "version": "==2.8"
+        },
         "pyyaml": {
             "hashes": [
                 "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9",
@@ -34,6 +55,21 @@
             ],
             "index": "pypi",
             "version": "==5.1.2"
+        },
+        "requests": {
+            "hashes": [
+                "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e",
+                "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b"
+            ],
+            "index": "pypi",
+            "version": "==2.21.0"
+        },
+        "urllib3": {
+            "hashes": [
+                "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4",
+                "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb"
+            ],
+            "version": "==1.24.3"
         }
     },
     "develop": {}
diff --git a/container-images/gerrit-init/tools/download_plugins.py b/container-images/gerrit-init/tools/download_plugins.py
new file mode 100755
index 0000000..3615b93
--- /dev/null
+++ b/container-images/gerrit-init/tools/download_plugins.py
@@ -0,0 +1,234 @@
+#!/usr/bin/python3
+
+# Copyright (C) 2019 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import argparse
+import hashlib
+import os
+import time
+
+from abc import ABC, abstractmethod
+
+import requests
+
+from init_config import InitConfig
+from log import get_logger
+
+LOG = get_logger("init")
+MAX_LOCK_LIFETIME = 60
+MAX_CACHED_VERSIONS = 5
+
+
+class InvalidPluginException(Exception):
+    """ Exception to be raised, if the downloaded plugin is not valid. """
+
+
+class AbstractPluginInstaller(ABC):
+    def __init__(self, site, config):
+        self.site = site
+        self.config = config
+
+        self.plugin_dir = os.path.join(site, "plugins")
+        self.plugins_changed = False
+
+    def _create_plugins_dir(self):
+        if not os.path.exists(self.plugin_dir):
+            os.makedirs(self.plugin_dir)
+            LOG.info("Created plugin installation directory: %s", self.plugin_dir)
+
+    def _get_installed_plugins(self):
+        if os.path.exists(self.plugin_dir):
+            return [f for f in os.listdir(self.plugin_dir) if f.endswith(".jar")]
+
+        return list()
+
+    @staticmethod
+    def _get_file_sha(file):
+        file_hash = hashlib.sha1()
+        with open(file, "rb") as f:
+            while True:
+                chunk = f.read(64000)
+                if not chunk:
+                    break
+                file_hash.update(chunk)
+
+        LOG.debug("SHA1 of file '%s' is %s", file, file_hash.hexdigest())
+
+        return file_hash.hexdigest()
+
+    def _remove_unwanted_plugins(self):
+        wanted_plugins = [plugin["name"] for plugin in self.config.downloaded_plugins]
+        wanted_plugins.extend(self.config.packaged_plugins)
+        for plugin in self._get_installed_plugins():
+            if os.path.splitext(plugin)[0] not in wanted_plugins:
+                os.remove(os.path.join(self.plugin_dir, plugin))
+                LOG.info("Removed plugin %s", plugin)
+
+    def execute(self):
+        self._create_plugins_dir()
+        self._remove_unwanted_plugins()
+
+        for plugin in self.config.downloaded_plugins:
+            self._install_plugin(plugin)
+
+    def _download_plugin(self, plugin, target):
+        LOG.info("Downloading %s plugin to %s", plugin["name"], target)
+        response = requests.get(plugin["url"])
+        with open(target, "wb") as f:
+            f.write(response.content)
+
+        file_sha = self._get_file_sha(target)
+
+        if file_sha != plugin["sha1"]:
+            os.remove(target)
+            raise InvalidPluginException(
+                (
+                    "SHA1 of downloaded file (%s) did not match expected SHA1 (%s). "
+                    "Removed downloaded file (%s)"
+                )
+                % (file_sha, plugin["sha1"], target)
+            )
+
+    @abstractmethod
+    def _install_plugin(self, plugin):
+        pass
+
+
+class PluginInstaller(AbstractPluginInstaller):
+    def _install_plugin(self, plugin):
+        target = os.path.join(self.plugin_dir, "%s.jar" % plugin["name"])
+        if os.path.exists(target) and self._get_file_sha(target) == plugin["sha1"]:
+            return
+
+        self._download_plugin(plugin, target)
+
+        self.plugins_changed = True
+
+
+class CachedPluginInstaller(AbstractPluginInstaller):
+    @staticmethod
+    def _cleanup_cache(plugin_cache_dir):
+        cached_files = [
+            os.path.join(plugin_cache_dir, f) for f in os.listdir(plugin_cache_dir)
+        ]
+        while len(cached_files) > MAX_CACHED_VERSIONS:
+            oldest_file = min(cached_files, key=os.path.getctime)
+            LOG.info(
+                "Too many cached files in %s. Removing file %s",
+                plugin_cache_dir,
+                oldest_file,
+            )
+            os.remove(oldest_file)
+            cached_files.remove(oldest_file)
+
+    @staticmethod
+    def _create_download_lock(lock_path):
+        with open(lock_path, "w") as f:
+            f.write(os.environ["HOSTNAME"])
+            LOG.debug("Created download lock %s", lock_path)
+
+    @staticmethod
+    def _create_plugin_cache_dir(plugin_cache_dir):
+        if not os.path.exists(plugin_cache_dir):
+            os.makedirs(plugin_cache_dir)
+            LOG.info("Created cache directory %s", plugin_cache_dir)
+
+    def _get_cached_plugin_path(self, plugin):
+        return os.path.join(
+            self.config.plugin_cache_dir,
+            plugin["name"],
+            "%s-%s.jar" % (plugin["name"], plugin["sha1"]),
+        )
+
+    def _install_from_cache_or_download(self, plugin, target):
+        cached_plugin_path = self._get_cached_plugin_path(plugin)
+
+        if os.path.exists(cached_plugin_path):
+            LOG.info("Installing %s plugin from cache.", plugin["name"])
+        else:
+            LOG.info("%s not found in cache. Downloading it.", plugin["name"])
+            download_target = self._get_cached_plugin_path(plugin)
+            self._create_plugin_cache_dir(os.path.dirname(target))
+
+            lock_path = "%s.lock" % download_target
+            while os.path.exists(lock_path):
+                LOG.info(
+                    "Download lock found (%s). Waiting %d seconds for it to be released.",
+                    lock_path,
+                    MAX_LOCK_LIFETIME,
+                )
+                lock_timestamp = os.path.getmtime(lock_path)
+                if time.time() > lock_timestamp + MAX_LOCK_LIFETIME:
+                    LOG.info("Stale download lock found (%s).", lock_path)
+                    self._remove_download_lock(lock_path)
+
+            self._create_download_lock(lock_path)
+
+            try:
+                self._download_plugin(plugin, download_target)
+            finally:
+                self._remove_download_lock(lock_path)
+
+        os.symlink(cached_plugin_path, target)
+        self._cleanup_cache(os.path.dirname(target))
+
+    def _install_plugin(self, plugin):
+        install_path = os.path.join(self.plugin_dir, "%s.jar" % plugin["name"])
+        if (
+            os.path.exists(install_path)
+            and self._get_file_sha(install_path) == plugin["sha1"]
+        ):
+            return
+
+        self.plugins_changed = True
+        self._install_from_cache_or_download(plugin, install_path)
+
+    @staticmethod
+    def _remove_download_lock(lock_path):
+        os.remove(lock_path)
+        LOG.debug("Removed download lock %s", lock_path)
+
+
+def get_installer(site, config):
+    plugin_installer = (
+        CachedPluginInstaller if config.plugin_cache_enabled else PluginInstaller
+    )
+    return plugin_installer(site, config)
+
+
+# pylint: disable=C0103
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser()
+    parser.add_argument(
+        "-s",
+        "--site",
+        help="Path to Gerrit site",
+        dest="site",
+        action="store",
+        default="/var/gerrit",
+        required=True,
+    )
+    parser.add_argument(
+        "-c",
+        "--config",
+        help="Path to configuration file for init process.",
+        dest="config",
+        action="store",
+        required=True,
+    )
+    args = parser.parse_args()
+
+    config = InitConfig().parse(args.config)
+    get_installer(args.site, config).execute()
diff --git a/container-images/gerrit-init/tools/gerrit_init.py b/container-images/gerrit-init/tools/gerrit_init.py
index f4be415..bef04c8 100755
--- a/container-images/gerrit-init/tools/gerrit_init.py
+++ b/container-images/gerrit-init/tools/gerrit_init.py
@@ -19,6 +19,7 @@
 import subprocess
 import sys
 
+from download_plugins import get_installer
 from git_config_parser import GitConfigParser
 from init_config import InitConfig
 from log import get_logger
@@ -31,6 +32,8 @@
         self.site = site
         self.config = config
 
+        self.plugin_installer = get_installer(self.site, self.config)
+
         self.gerrit_config = self._parse_gerrit_config()
         self.is_slave = self._is_slave()
         self.installed_plugins = self._get_installed_plugins()
@@ -65,11 +68,6 @@
 
         return installed_plugins
 
-    def _remove_unwanted_plugins(self):
-        for plugin in self.installed_plugins.difference(self.config.packaged_plugins):
-            LOG.info("Removing plugin %s", plugin)
-            os.remove(os.path.join(self.site, "plugins", "%s.jar" % plugin))
-
     def _gerrit_war_updated(self):
         installed_war_path = os.path.join(self.site, "bin", "gerrit.war")
         installed_version = self._get_gerrit_version(installed_war_path)
@@ -82,6 +80,10 @@
         return installed_version != provided_version
 
     def _needs_init(self):
+        if self.plugin_installer.plugins_changed:
+            LOG.info("Plugins were installed or updated. Initializing.")
+            return True
+
         installed_war_path = os.path.join(self.site, "bin", "gerrit.war")
         if not os.path.exists(installed_war_path):
             LOG.info("Gerrit is not yet installed. Initializing new site.")
@@ -99,7 +101,7 @@
         return False
 
     def execute(self):
-        self._remove_unwanted_plugins()
+        self.plugin_installer.execute()
 
         if not self._needs_init():
             return
diff --git a/container-images/gerrit-init/tools/init_config.py b/container-images/gerrit-init/tools/init_config.py
index 31d9394..266fdde 100644
--- a/container-images/gerrit-init/tools/init_config.py
+++ b/container-images/gerrit-init/tools/init_config.py
@@ -19,7 +19,10 @@
 
 class InitConfig:
     def __init__(self):
+        self.downloaded_plugins = list()
+        self.plugin_cache_enabled = False
         self.packaged_plugins = set()
+        self.plugin_cache_dir = None
 
     def parse(self, config_file):
         if not os.path.exists(config_file):
@@ -31,7 +34,13 @@
         if config is None:
             raise ValueError("Invalid config-file: %s" % config_file)
 
+        if "downloadedPlugins" in config:
+            self.downloaded_plugins = config["downloadedPlugins"]
         if "packagedPlugins" in config:
             self.packaged_plugins = set(config["packagedPlugins"])
+        if "pluginCache" in config:
+            self.plugin_cache_enabled = config["pluginCache"]
+        if "pluginCacheDir" in config and config["pluginCacheDir"]:
+            self.plugin_cache_dir = config["pluginCacheDir"]
 
         return self
diff --git a/helm-charts/gerrit-master/README.md b/helm-charts/gerrit-master/README.md
index 702d17f..be13361 100644
--- a/helm-charts/gerrit-master/README.md
+++ b/helm-charts/gerrit-master/README.md
@@ -165,6 +165,12 @@
 | `gerritMaster.ingress.tls.key`               | Private SSL server certificate                                                                  | `-----BEGIN RSA PRIVATE KEY-----`                                                        |
 | `gerritMaster.keystore`                      | base64-encoded Java keystore (`cat keystore.jks | base64`) to be used by Gerrit, when using SSL | `nil`                                                                                    |
 | `gerritMaster.plugins.packaged`              | List of Gerrit plugins that are packaged into the Gerrit-war-file to install                    | `["commit-message-length-validator", "download-commands", "replication", "reviewnotes"]` |
+| `gerritMaster.plugins.downloaded`            | List of Gerrit plugins that will be downloaded                                                  | `nil`                                                                                    |
+| `gerritMaster.plugins.downloaded[0].name`    | Name of plugin                                                                                  | `nil`                                                                                    |
+| `gerritMaster.plugins.downloaded[0].url`     | Download url of plugin                                                                          | `nil`                                                                                    |
+| `gerritMaster.plugins.downloaded[0].sha1`    | SHA1 sum of plugin jar used to ensure file integrity and version (optional)                     | `nil`                                                                                    |
+| `gerritMaster.plugins.cache.enabled`         | Whether to cache downloaded plugins                                                             | `false`                                                                                  |
+| `gerritMaster.plugins.cache.size`            | Size of the volume used to store cached plugins                                                 | `1Gi`                                                                                    |
 | `gerritMaster.config.gerrit`                 | The contents of the gerrit.config                                                               | [see here](#Gerrit-config-files)                                                         |
 | `gerritMaster.config.secure`                 | The contents of the secure.config                                                               | [see here](#Gerrit-config-files)                                                         |
 | `gerritMaster.config.replication`            | The contents of the replication.config                                                          | [see here](#Gerrit-config-files)                                                         |
@@ -220,6 +226,24 @@
     memory resource limit set for the container (e.g. `-Xmx4g`). In your calculation,
     allow memory for other components running in the container.
 
+### Installing Gerrit plugins
+
+There are several different ways to install plugins for Gerrit:
+
+- **RECOMMENDED: Package the plugins to install into the WAR-file containing Gerrit.**
+  This method provides the most stable way to install plugins, but requires to
+  use a custom built gerrit-war file and container images, if plugins are required
+  that are not part of the official `release.war`-file.
+
+- **Download and cache plugins.** The chart supports downloading the plugin files and
+  to cache them in a separate volume, that is shared between Gerrit-pods. SHA1-
+  sums are used to validate plugin-files and versions.
+
+- **Download plugins, but do not cache them.** This should only be used during
+  development to save resources (the shared volume). Each pod will download the
+  plugin-files on its own. Pods will fail to start up, if the download-URL is
+  not valid anymore at some point in time.
+
 ## Upgrading the Chart
 
 To upgrade an existing installation of the gerrit-master chart, e.g. to install
diff --git a/helm-charts/gerrit-master/templates/gerrit-master.configmap.yaml b/helm-charts/gerrit-master/templates/gerrit-master.configmap.yaml
index 30cddc6..814fb68 100644
--- a/helm-charts/gerrit-master/templates/gerrit-master.configmap.yaml
+++ b/helm-charts/gerrit-master/templates/gerrit-master.configmap.yaml
@@ -24,7 +24,13 @@
     release: {{ .Release.Name }}
 data:
   gerrit-init.yaml: |-
+    pluginCache: {{ .Values.gerritMaster.plugins.cache.enabled }}
+    pluginCacheDir: /var/mnt/plugins
     {{- if .Values.gerritMaster.plugins.packaged }}
     packagedPlugins:
 {{ toYaml .Values.gerritMaster.plugins.packaged | indent 6}}
     {{- end }}
+    {{- if .Values.gerritMaster.plugins.downloaded }}
+    downloadedPlugins:
+{{ toYaml .Values.gerritMaster.plugins.downloaded | indent 6 }}
+    {{- end }}
diff --git a/helm-charts/gerrit-master/templates/gerrit-master.stateful-set.yaml b/helm-charts/gerrit-master/templates/gerrit-master.stateful-set.yaml
index dd8575f..9448ddd 100644
--- a/helm-charts/gerrit-master/templates/gerrit-master.stateful-set.yaml
+++ b/helm-charts/gerrit-master/templates/gerrit-master.stateful-set.yaml
@@ -84,6 +84,10 @@
         - name: gerrit-init-config
           mountPath: "/var/config/gerrit-init.yaml"
           subPath: gerrit-init.yaml
+        {{- if and .Values.gerritMaster.plugins.cache.enabled .Values.gerritMaster.plugins.downloaded }}
+        - name: gerrit-plugin-cache
+          mountPath: "/var/mnt/plugins"
+        {{- end }}
         - name: gerrit-config
           mountPath: "/var/config/gerrit.config"
           subPath: gerrit.config
@@ -138,6 +142,11 @@
       - name: gerrit-site
         emptyDir: {}
       {{- end }}
+      {{- if and .Values.gerritMaster.plugins.cache.enabled .Values.gerritMaster.plugins.downloaded }}
+      - name: gerrit-plugin-cache
+        persistentVolumeClaim:
+          claimName: {{ .Release.Name }}-plugin-cache-pvc
+      {{- end }}
       - name: git-filesystem
         persistentVolumeClaim:
           {{- if .Values.gitRepositoryStorage.externalPVC.use }}
diff --git a/helm-charts/gerrit-master/templates/gerrit-master.storage.yaml b/helm-charts/gerrit-master/templates/gerrit-master.storage.yaml
new file mode 100644
index 0000000..300afa6
--- /dev/null
+++ b/helm-charts/gerrit-master/templates/gerrit-master.storage.yaml
@@ -0,0 +1,18 @@
+{{- if and .Values.gerritMaster.plugins.cache.enabled .Values.gerritMaster.plugins.downloaded }}
+kind: PersistentVolumeClaim
+apiVersion: v1
+metadata:
+  name: {{ .Release.Name }}-plugin-cache-pvc
+  labels:
+    app: gerrit-master
+    chart: {{ template "gerrit-master.chart" . }}
+    heritage: {{ .Release.Service }}
+    release: {{ .Release.Name }}
+spec:
+  accessModes:
+  - ReadWriteMany
+  resources:
+    requests:
+      storage: {{ .Values.gerritMaster.plugins.cache.size }}
+  storageClassName: {{ .Values.storageClasses.shared.name }}
+{{- end }}
diff --git a/helm-charts/gerrit-master/values.yaml b/helm-charts/gerrit-master/values.yaml
index ccdabf9..67aef6c 100644
--- a/helm-charts/gerrit-master/values.yaml
+++ b/helm-charts/gerrit-master/values.yaml
@@ -132,6 +132,16 @@
     - download-commands
     - replication
     - reviewnotes
+    downloaded:
+    # - name: delete-project
+    #   url: https://example.com/gerrit-plugins/delete-project.jar
+    #   sha1:
+
+    # Only downloaded plugins will be cached. This will be ignored, if no plugins
+    # are downloaded.
+    cache:
+      enabled: false
+      size: 1Gi
 
   config:
     # Some values are expected to have a specific value for the deployment installed
diff --git a/tests/container-images/gerrit-init/test_container_structure_gerrit_init.py b/tests/container-images/gerrit-init/test_container_structure_gerrit_init.py
index 38d37ec..ab94fc8 100755
--- a/tests/container-images/gerrit-init/test_container_structure_gerrit_init.py
+++ b/tests/container-images/gerrit-init/test_container_structure_gerrit_init.py
@@ -27,6 +27,7 @@
 @pytest.fixture(
     scope="function",
     params=[
+        "/var/tools/download_plugins.py",
         "/var/tools/gerrit_init.py",
         "/var/tools/git_config_parser.py",
         "/var/tools/init_config.py",
@@ -41,7 +42,7 @@
     return request.param
 
 
-@pytest.fixture(scope="function", params=["pyyaml"])
+@pytest.fixture(scope="function", params=["pyyaml", "requests"])
 def expected_pip_package(request):
     return request.param
 
diff --git a/tests/helm-charts/gerrit-master/conftest.py b/tests/helm-charts/gerrit-master/conftest.py
index 1331fc3..99cb187 100644
--- a/tests/helm-charts/gerrit-master/conftest.py
+++ b/tests/helm-charts/gerrit-master/conftest.py
@@ -35,7 +35,7 @@
     gitgc_image,
     gerrit_init_image,
 ):
-    def install_chart(chart_opts):
+    def install_chart(chart_opts, wait=True):
         chart_path = os.path.join(repository_root, "helm-charts", "gerrit-master")
         chart_name = "gerrit-master-" + utils.create_random_string()
         namespace_name = utils.create_random_string()
@@ -67,6 +67,7 @@
             set_values=chart_opts,
             fail_on_err=True,
             namespace=namespace_name,
+            wait=wait,
         )
 
         return {"chart": chart_path, "name": chart_name, "namespace": namespace_name}
diff --git a/tests/helm-charts/gerrit-master/test_chart_gerrit_master_plugins.py b/tests/helm-charts/gerrit-master/test_chart_gerrit_master_plugins.py
index d4470ec..2426da3 100644
--- a/tests/helm-charts/gerrit-master/test_chart_gerrit_master_plugins.py
+++ b/tests/helm-charts/gerrit-master/test_chart_gerrit_master_plugins.py
@@ -14,14 +14,36 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import hashlib
 import json
 import time
 
 import pytest
 import requests
 
+from kubernetes import client
+
 import utils
 
+PLUGINS = ["owners", "reviewers"]
+GERRIT_VERSION = "3.0"
+
+
+@pytest.fixture(scope="module")
+def plugin_list():
+    plugin_list = list()
+    for plugin in PLUGINS:
+        url = (
+            "https://gerrit-ci.gerritforge.com/view/Plugins-stable-{gerrit_version}/"
+            "job/plugin-{plugin}-bazel-stable-{gerrit_version}/lastSuccessfulBuild/"
+            "artifact/bazel-bin/plugins/{plugin}/{plugin}.jar"
+        ).format(plugin=plugin, gerrit_version=GERRIT_VERSION)
+        jar = requests.get(url, verify=False).content
+        plugin_list.append(
+            {"name": plugin, "url": url, "sha1": hashlib.sha1(jar).hexdigest()}
+        )
+    return plugin_list
+
 
 @pytest.fixture(scope="class")
 def gerrit_master_deployment_with_plugins_factory(
@@ -66,6 +88,63 @@
     test_cluster.delete_namespace(chart["namespace"])
 
 
+@pytest.fixture(
+    scope="class", params=[1, 2], ids=["single-other-plugin", "multiple-other-plugins"]
+)
+def gerrit_master_deployment_with_other_plugins(
+    request,
+    docker_tag,
+    test_cluster,
+    plugin_list,
+    gerrit_master_deployment_with_plugins_factory,
+):
+    chart_opts = {
+        "images.registry.name": request.config.getoption("--registry"),
+        "images.version": docker_tag,
+        "images.ImagePullPolicy": "IfNotPresent",
+        "gerritMaster.ingress.host": "master.%s"
+        % request.config.getoption("--ingress-url"),
+    }
+    selected_plugins = plugin_list[: request.param]
+    for counter, plugin in enumerate(selected_plugins):
+        chart_opts["gerritMaster.plugins.downloaded[%d].name" % counter] = plugin[
+            "name"
+        ]
+        chart_opts["gerritMaster.plugins.downloaded[%d].url" % counter] = plugin["url"]
+        chart_opts["gerritMaster.plugins.downloaded[%d].sha1" % counter] = plugin[
+            "sha1"
+        ]
+    chart = gerrit_master_deployment_with_plugins_factory(chart_opts)
+    chart["installed_plugins"] = selected_plugins
+
+    yield chart
+
+    test_cluster.helm.delete(chart["name"])
+
+
+@pytest.fixture()
+def gerrit_master_deployment_with_other_plugin_wrong_sha(
+    request, docker_tag, test_cluster, plugin_list, gerrit_master_deployment_factory
+):
+    chart_opts = {
+        "images.registry.name": request.config.getoption("--registry"),
+        "images.version": docker_tag,
+        "images.ImagePullPolicy": "IfNotPresent",
+        "gerritMaster.ingress.host": "master.%s"
+        % request.config.getoption("--ingress-url"),
+    }
+    plugin = plugin_list[0]
+    chart_opts["gerritMaster.plugins.downloaded[0].name"] = plugin["name"]
+    chart_opts["gerritMaster.plugins.downloaded[0].url"] = plugin["url"]
+    chart_opts["gerritMaster.plugins.downloaded[0].sha1"] = "notAValidSha"
+    chart = gerrit_master_deployment_factory(chart_opts, wait=False)
+    chart["installed_plugins"] = plugin
+
+    yield chart
+
+    test_cluster.helm.delete(chart["name"])
+
+
 def update_chart(helm, chart, opts):
     helm.upgrade(
         chart=chart["chart"],
@@ -141,3 +220,121 @@
 
         assert chart["removed_plugin"] not in response
         self._assert_installed_plugins(chart["installed_plugins"], response)
+
+
+@pytest.mark.slow
+@pytest.mark.incremental
+@pytest.mark.integration
+@pytest.mark.kubernetes
+class TestGerritMasterChartOtherPluginInstall:
+    def _remove_plugin_from_install_list(self, installed_plugins):
+        removed_plugin = installed_plugins.pop()
+        plugin_install_list = dict()
+        if installed_plugins:
+            for counter, plugin in enumerate(installed_plugins):
+                plugin_install_list[
+                    "gerritMaster.plugins.downloaded[%d].name" % counter
+                ] = plugin["name"]
+                plugin_install_list[
+                    "gerritMaster.plugins.downloaded[%d].url" % counter
+                ] = plugin["url"]
+                plugin_install_list[
+                    "gerritMaster.plugins.downloaded[%d].sha1" % counter
+                ] = plugin["sha1"]
+        else:
+            plugin_install_list["gerritMaster.plugins.downloaded"] = "false"
+        return plugin_install_list, removed_plugin, installed_plugins
+
+    def _assert_installed_plugins(self, expected_plugins, installed_plugins):
+        for plugin in expected_plugins:
+            assert plugin["name"] in installed_plugins
+            assert (
+                installed_plugins[plugin["name"]]["filename"]
+                == "%s.jar" % plugin["name"]
+            )
+
+    @pytest.mark.timeout(300)
+    def test_install_other_plugins(
+        self, request, test_cluster, gerrit_master_deployment_with_other_plugins
+    ):
+        response = None
+        while not response:
+            try:
+                response = get_gerrit_plugin_list(
+                    "http://master.%s" % (request.config.getoption("--ingress-url"))
+                )
+            except requests.exceptions.ConnectionError:
+                continue
+        self._assert_installed_plugins(
+            gerrit_master_deployment_with_other_plugins["installed_plugins"], response
+        )
+
+    @pytest.mark.timeout(300)
+    def test_install_other_plugins_are_removed_with_update(
+        self, request, test_cluster, gerrit_master_deployment_with_other_plugins
+    ):
+        chart = gerrit_master_deployment_with_other_plugins
+        chart_opts, chart["removed_plugin"], chart[
+            "installed_plugin"
+        ] = self._remove_plugin_from_install_list(chart["installed_plugins"])
+        update_chart(test_cluster.helm, chart, chart_opts)
+
+        response = None
+        while True:
+            try:
+                response = get_gerrit_plugin_list(
+                    "http://master.%s" % (request.config.getoption("--ingress-url"))
+                )
+                if (
+                    response is not None
+                    and chart["removed_plugin"]["name"] not in response
+                ):
+                    break
+            except requests.exceptions.ConnectionError:
+                time.sleep(1)
+
+        assert chart["removed_plugin"]["name"] not in response
+        self._assert_installed_plugins(chart["installed_plugins"], response)
+
+
+@pytest.mark.integration
+@pytest.mark.kubernetes
+@pytest.mark.timeout(180)
+def test_install_other_plugins_fails_wrong_sha(
+    request, test_cluster, gerrit_master_deployment_with_other_plugin_wrong_sha
+):
+    pod_labels = "app=gerrit-master,release=%s" % (
+        gerrit_master_deployment_with_other_plugin_wrong_sha["name"]
+    )
+    core_v1 = client.CoreV1Api()
+    pod_name = ""
+    while not pod_name:
+        pod_list = core_v1.list_namespaced_pod(
+            namespace=gerrit_master_deployment_with_other_plugin_wrong_sha["namespace"],
+            watch=False,
+            label_selector=pod_labels,
+        )
+        if len(pod_list.items) > 1:
+            raise RuntimeError(
+                "Too many gerrit-master pods with the same release name."
+            )
+        pod_name = pod_list.items[0].metadata.name
+
+    current_status = None
+    while not current_status:
+        pod = core_v1.read_namespaced_pod_status(
+            pod_name, gerrit_master_deployment_with_other_plugin_wrong_sha["namespace"]
+        )
+        if not pod.status.init_container_statuses:
+            time.sleep(1)
+            continue
+        for init_container_status in pod.status.init_container_statuses:
+            if (
+                init_container_status.name == "gerrit-init"
+                and init_container_status.last_state.terminated
+            ):
+                current_status = init_container_status
+                assert current_status.last_state.terminated.exit_code > 0
+                return
+
+    assert current_status.last_state.terminated.exit_code > 0
diff --git a/tests/helpers/helm.py b/tests/helpers/helm.py
index 1c5eb3f..18a0c2c 100644
--- a/tests/helpers/helm.py
+++ b/tests/helpers/helm.py
@@ -69,29 +69,31 @@
         set_values=None,
         namespace=None,
         fail_on_err=True,
+        wait=True,
     ):
         """Installs a chart on the cluster
 
-    Arguments:
-      chart {str} -- Release name or path of a helm chart
-      name {str} -- Name with which the chart will be installed on the cluster
+      Arguments:
+        chart {str} -- Release name or path of a helm chart
+        name {str} -- Name with which the chart will be installed on the cluster
 
-    Keyword Arguments:
-      values_file {str} -- Path to a custom values.yaml file (default: {None})
-      set_values {dict} -- Dictionary containing key-value-pairs that are used
-                           to overwrite values in the values.yaml-file.
-                           (default: {None})
-      namespace {str} -- Namespace to install the release into (default: {default})
-      fail_on_err {bool} -- Whether to fail with an exception if the installation
-                            fails (default: {True})
+      Keyword Arguments:
+        values_file {str} -- Path to a custom values.yaml file (default: {None})
+        set_values {dict} -- Dictionary containing key-value-pairs that are used
+                            to overwrite values in the values.yaml-file.
+                            (default: {None})
+        namespace {str} -- Namespace to install the release into (default: {default})
+        fail_on_err {bool} -- Whether to fail with an exception if the installation
+                              fails (default: {True})
+        wait {bool} -- Whether to wait for all pods to be ready (default: {True})
 
-    Returns:
-      CompletedProcess -- CompletedProcess-object returned by subprocess
-                          containing details about the result and output of the
-                          executed command.
-    """
+      Returns:
+        CompletedProcess -- CompletedProcess-object returned by subprocess
+                            containing details about the result and output of the
+                            executed command.
+      """
 
-        helm_cmd = ["install", chart, "--dep-up", "-n", name, "--wait"]
+        helm_cmd = ["install", chart, "--dep-up", "-n", name]
         if values_file:
             helm_cmd.extend(("-f", values_file))
         if set_values:
@@ -99,6 +101,8 @@
             helm_cmd.extend(("--set", ",".join(opt_list)))
         if namespace:
             helm_cmd.extend(("--namespace", namespace))
+        if wait:
+            helm_cmd.append("--wait")
         return self._exec_command(helm_cmd, fail_on_err)
 
     def list(self):