| #!/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 hashlib |
| import os |
| import shutil |
| import time |
| |
| from abc import ABC, abstractmethod |
| from zipfile import ZipFile |
| |
| import requests |
| |
| from .reindex import IndexType, get_index_type |
| from ..constants import SITE_PATH, SITE_PLUGIN_PATH, SITE_LIB_PATH |
| from ..helpers import log |
| from ..config.cluster_mode import ClusterMode |
| |
| LOG = log.get_logger("init") |
| MAX_LOCK_LIFETIME = 60 |
| MAX_CACHED_VERSIONS = 5 |
| |
| REQUIRED_PLUGINS = ["healthcheck"] |
| REQUIRED_HA_PLUGINS = ["high-availability"] |
| REQUIRED_HA_LIBS = ["high-availability", "global-refdb"] |
| REQUIRED_MULTISITE_LIBS = [ |
| "events-broker", |
| "global-refdb", |
| "multi-site", |
| "pull-replication", |
| ] |
| REQUIRED_MULTISITE_PLUGINS = [ |
| "events-kafka", |
| "multi-site", |
| "pull-replication", |
| "websession-broker", |
| ] |
| |
| |
| class InvalidPluginException(Exception): |
| """Exception to be raised, if the downloaded plugin is not valid.""" |
| |
| |
| class MissingRequiredPluginException(Exception): |
| """Exception to be raised, if the downloaded plugin is not valid.""" |
| |
| |
| class AbstractPluginInstaller(ABC): |
| def __init__(self, gerrit_config, config): |
| self.gerrit_config = gerrit_config |
| self.config = config |
| |
| self.required_plugins = self._get_required_plugins() |
| self.required_libs = self._get_required_libs() |
| |
| self.plugins_changed = False |
| |
| def _create_plugins_dir(self): |
| if not os.path.exists(SITE_PLUGIN_PATH): |
| os.makedirs(SITE_PLUGIN_PATH) |
| LOG.info("Created plugin installation directory: %s", SITE_PLUGIN_PATH) |
| |
| def _create_lib_dir(self): |
| if not os.path.exists(SITE_LIB_PATH): |
| os.makedirs(SITE_LIB_PATH) |
| LOG.info("Created lib installation directory: %s", SITE_LIB_PATH) |
| |
| def _get_installed_plugins(self): |
| return self._get_installed_jars(SITE_PLUGIN_PATH) |
| |
| def _get_installed_libs(self): |
| return self._get_installed_jars(SITE_LIB_PATH) |
| |
| @staticmethod |
| def _get_installed_jars(dir): |
| if os.path.exists(dir): |
| return [f for f in os.listdir(dir) if f.endswith(".jar")] |
| |
| return [] |
| |
| def _get_required_plugins(self): |
| required = REQUIRED_PLUGINS.copy() |
| if self.config.is_ha: |
| required.extend(REQUIRED_HA_PLUGINS) |
| elif self.config.cluster_mode == ClusterMode.MULTISITE: |
| required.extend(REQUIRED_MULTISITE_PLUGINS) |
| if self.config.refdb: |
| required.append(f"{self.config.refdb}-refdb") |
| if get_index_type(self.gerrit_config) == IndexType.ELASTICSEARCH: |
| required.append("index-elasticsearch") |
| LOG.info( |
| "Requiring plugins (ClusterMode: %s): %s", |
| self.config.cluster_mode.name, |
| required, |
| ) |
| return required |
| |
| def _get_required_libs(self): |
| required = [] |
| if self.config.is_ha: |
| required.extend(REQUIRED_HA_LIBS) |
| elif self.config.cluster_mode == ClusterMode.MULTISITE: |
| required.extend(REQUIRED_MULTISITE_LIBS) |
| elif self.config.refdb: |
| required.append("global-refdb") |
| if get_index_type(self.gerrit_config) == IndexType.ELASTICSEARCH: |
| required.append("index-elasticsearch") |
| LOG.info( |
| "Requiring libs (ClusterMode: %s): %s", |
| self.config.cluster_mode.name, |
| required, |
| ) |
| return required |
| |
| def _install_required_plugins(self): |
| for plugin in self.required_plugins: |
| if plugin in self.config.get_plugin_names(): |
| continue |
| |
| self._install_required_jar(plugin, SITE_PLUGIN_PATH) |
| |
| def _install_required_libs(self): |
| for lib in self.required_libs: |
| if lib in self.config.get_lib_names(): |
| continue |
| |
| self._install_required_jar(lib, SITE_LIB_PATH) |
| |
| def _install_required_jar(self, jar, target_dir): |
| with ZipFile("/var/war/gerrit.war", "r") as war: |
| # Lib modules can be packaged as a plugin. However, they could |
| # currently not be installed by the init pgm tool. |
| if f"WEB-INF/plugins/{jar}.jar" in war.namelist(): |
| self._install_plugin_from_war(jar, target_dir) |
| return |
| try: |
| self._install_jar_from_container(jar, target_dir) |
| except FileNotFoundError: |
| raise MissingRequiredPluginException(f"Required jar {jar} was not found.") |
| |
| def _install_jar_from_container(self, plugin, target_dir): |
| source_file = os.path.join("/var/plugins", plugin + ".jar") |
| target_file = os.path.join(target_dir, plugin + ".jar") |
| LOG.info( |
| "Installing plugin %s from container to %s.", |
| plugin, |
| target_file, |
| ) |
| if not os.path.exists(source_file): |
| raise FileNotFoundError( |
| "Unable to find required plugin in container: " + plugin |
| ) |
| if os.path.exists(target_file) and self._get_file_sha( |
| source_file |
| ) == self._get_file_sha(target_file): |
| return |
| |
| shutil.copyfile(source_file, target_file) |
| self.plugins_changed = True |
| |
| def _install_plugins_from_war(self): |
| for plugin in self.config.get_packaged_plugins(): |
| self._install_plugin_from_war(plugin["name"], SITE_PLUGIN_PATH) |
| |
| def _install_plugin_from_war(self, plugin, target_dir): |
| LOG.info("Installing packaged plugin %s.", plugin) |
| with ZipFile("/var/war/gerrit.war", "r") as war: |
| war.extract(f"WEB-INF/plugins/{plugin}.jar", SITE_PLUGIN_PATH) |
| |
| source_file = f"{SITE_PLUGIN_PATH}/WEB-INF/plugins/{plugin}.jar" |
| target_file = os.path.join(target_dir, f"{plugin}.jar") |
| if not os.path.exists(target_file) or self._get_file_sha( |
| source_file |
| ) != self._get_file_sha(target_file): |
| os.rename(source_file, target_file) |
| self.plugins_changed = True |
| |
| shutil.rmtree(os.path.join(SITE_PLUGIN_PATH, "WEB-INF"), ignore_errors=True) |
| |
| @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 = list(self.config.get_plugins()) |
| wanted_plugins.extend(self.required_plugins) |
| self._remove_unwanted( |
| wanted_plugins, self._get_installed_plugins(), SITE_PLUGIN_PATH |
| ) |
| |
| def _remove_unwanted_libs(self): |
| wanted_libs = list(self.config.get_libs()) |
| wanted_libs.extend(self.required_libs) |
| wanted_libs.extend(self.config.get_plugins_installed_as_lib()) |
| self._remove_unwanted(wanted_libs, self._get_installed_libs(), SITE_LIB_PATH) |
| |
| @staticmethod |
| def _remove_unwanted(wanted, installed, dir): |
| for plugin in installed: |
| if os.path.splitext(plugin)[0] not in wanted: |
| os.remove(os.path.join(dir, plugin)) |
| LOG.info("Removed plugin %s", plugin) |
| |
| def _symlink_plugins_to_lib(self): |
| if not os.path.exists(SITE_LIB_PATH): |
| os.makedirs(SITE_LIB_PATH) |
| else: |
| for f in os.listdir(SITE_LIB_PATH): |
| path = os.path.join(SITE_LIB_PATH, f) |
| if ( |
| os.path.islink(path) |
| and os.path.splitext(f)[0] |
| not in self.config.get_plugins_installed_as_lib() |
| ): |
| os.unlink(path) |
| LOG.info("Removed symlink %s", f) |
| for lib in self.config.get_plugins_installed_as_lib(): |
| plugin_path = os.path.join(SITE_PLUGIN_PATH, f"{lib}.jar") |
| if os.path.exists(plugin_path): |
| try: |
| os.symlink(plugin_path, os.path.join(SITE_LIB_PATH, f"{lib}.jar")) |
| except FileExistsError: |
| continue |
| else: |
| raise FileNotFoundError( |
| f"Could not find plugin {lib} to symlink to lib-directory." |
| ) |
| |
| def execute(self): |
| self._create_plugins_dir() |
| self._create_lib_dir() |
| |
| self._remove_unwanted_plugins() |
| self._remove_unwanted_libs() |
| |
| self._install_required_plugins() |
| self._install_required_libs() |
| |
| self._install_plugins_from_war() |
| |
| for plugin in self.config.get_downloaded_plugins(): |
| self._install_plugin(plugin) |
| |
| for plugin in self.config.get_libs(): |
| self._install_lib(plugin) |
| |
| self._symlink_plugins_to_lib() |
| |
| def _download_plugin(self, plugin, target): |
| LOG.info("Downloading %s plugin to %s", plugin["name"], target) |
| try: |
| response = requests.get(plugin["url"]) |
| except requests.exceptions.SSLError: |
| response = requests.get(plugin["url"], verify=self.config.ca_cert_path) |
| |
| 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( |
| ( |
| f"SHA1 of downloaded file ({file_sha}) did not match " |
| f"expected SHA1 ({plugin['sha1']}). " |
| f"Removed downloaded file ({target})" |
| ) |
| ) |
| |
| def _install_plugin(self, plugin): |
| self._install_jar(plugin, SITE_PLUGIN_PATH) |
| |
| def _install_lib(self, lib): |
| self._install_jar(lib, SITE_LIB_PATH) |
| |
| @abstractmethod |
| def _install_jar(self, plugin, target_dir): |
| pass |
| |
| |
| class PluginInstaller(AbstractPluginInstaller): |
| def _install_jar(self, plugin, target_dir): |
| target = os.path.join(target_dir, f"{plugin['name']}.jar") |
| 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", encoding="utf-8") 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"], |
| f"{plugin['name']}-{plugin['sha1']}.jar", |
| ) |
| |
| 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"]) |
| self._create_plugin_cache_dir(os.path.dirname(cached_plugin_path)) |
| |
| lock_path = f"{cached_plugin_path}.lock" |
| 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, cached_plugin_path) |
| finally: |
| self._remove_download_lock(lock_path) |
| |
| shutil.copy(cached_plugin_path, target) |
| self._cleanup_cache(os.path.dirname(cached_plugin_path)) |
| |
| def _install_jar(self, plugin, target_dir): |
| install_path = os.path.join(target_dir, f"{plugin['name']}.jar") |
| 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(gerrit_config, config): |
| plugin_installer = ( |
| CachedPluginInstaller if config.plugin_cache_enabled else PluginInstaller |
| ) |
| return plugin_installer(gerrit_config, config) |