backstage-demo-janus-showcase/packages/backend/install-dynamic-plugins.py
Med Mouine 089a1cd890 config
2024-04-18 10:23:04 -04:00

305 lines
13 KiB
Python
Executable File

#
# Copyright (c) 2023 Red Hat, Inc.
# 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 os
import sys
import yaml
import tarfile
import shutil
import subprocess
import base64
import binascii
# This script is used to install dynamic plugins in the Backstage application,
# and is available in the container image to be called at container initialization,
# for example in an init container when using Kubernetes.
#
# It expects, as the only argument, the path to the root directory where
# the dynamic plugins will be installed.
#
# Additionally, the MAX_ENTRY_SIZE environment variable can be defined to set
# the maximum size of a file in the archive (default: 20MB).
#
# The SKIP_INTEGRITY_CHECK environment variable can be defined with ("true") to skip the integrity check of remote packages
#
# It expects the `dynamic-plugins.yaml` file to be present in the current directory and
# to contain the list of plugins to install along with their optional configuration.
#
# The `dynamic-plugins.yaml` file must contain:
# - a `plugins` list of objects with the following properties:
# - `package`: the NPM package to install (either a package name or a path to a local package)
# - `integrity`: a string containing the integrity hash of the package (optional if package is local, as integrity check is not checked for local packages)
# - `pluginConfig`: an optional plugin-specific configuration fragment
# - `disabled`: an optional boolean to disable the plugin (`false` by default)
# - an optional `includes` list of yaml files to include, each file containing a list of plugins.
#
# The plugins listed in the included files will be included in the main list of considered plugins
# and possibly overwritten by the plugins already listed in the main `plugins` list.
#
# For each enabled plugin mentioned in the main `plugins` list and the various included files,
# the script will:
# - call `npm pack` to get the package archive and extract it in the dynamic plugins root directory
# - if the package comes from a remote registry, verify the integrity of the package with the given integrity hash
# - merge the plugin-specific configuration fragment in a global configuration file named `app-config.dynamic-plugins.yaml`
#
class InstallException(Exception):
"""Exception class from which every exception in this library will derive."""
pass
def merge(source, destination, prefix = ''):
for key, value in source.items():
if isinstance(value, dict):
# get node or create one
node = destination.setdefault(key, {})
merge(value, node, key + '.')
else:
# if key exists in destination trigger an error
if key in destination and destination[key] != value:
raise InstallException(f"Config key '{ prefix + key }' defined differently for 2 dynamic plugins")
destination[key] = value
return destination
RECOGNIZED_ALGORITHMS = (
'sha512',
'sha384',
'sha256',
)
def verify_package_integrity(plugin: dict, archive: str, working_directory: str) -> None:
package = plugin['package']
if 'integrity' not in plugin:
raise InstallException(f'Package integrity for {package} is missing')
integrity = plugin['integrity']
if not isinstance(integrity, str):
raise InstallException(f'Package integrity for {package} must be a string')
integrity = integrity.split('-')
if len(integrity) != 2:
raise InstallException(f'Package integrity for {package} must be a string of the form <algorithm>-<hash>')
algorithm = integrity[0]
if algorithm not in RECOGNIZED_ALGORITHMS:
raise InstallException(f'{package}: Provided Package integrity algorithm {algorithm} is not supported, please use one of following algorithms {RECOGNIZED_ALGORITHMS} instead')
hash_digest = integrity[1]
try:
base64.b64decode(hash_digest, validate=True)
except binascii.Error:
raise InstallException(f'{package}: Provided Package integrity hash {hash_digest} is not a valid base64 encoding')
cat_process = subprocess.Popen(["cat", archive], stdout=subprocess.PIPE)
openssl_dgst_process = subprocess.Popen(["openssl", "dgst", "-" + algorithm, "-binary"], stdin=cat_process.stdout, stdout=subprocess.PIPE)
openssl_base64_process = subprocess.Popen(["openssl", "base64", "-A"], stdin=openssl_dgst_process.stdout, stdout=subprocess.PIPE)
output, _ = openssl_base64_process.communicate()
if hash_digest != output.decode('utf-8').strip():
raise InstallException(f'{package}: The hash of the downloaded package {output.decode("utf-8").strip()} does not match the provided integrity hash {hash_digest} provided in the configuration file')
def main():
dynamicPluginsRoot = sys.argv[1]
maxEntrySize = int(os.environ.get('MAX_ENTRY_SIZE', 20000000))
skipIntegrityCheck = os.environ.get("SKIP_INTEGRITY_CHECK", "").lower() == "true"
dynamicPluginsFile = 'dynamic-plugins.yaml'
dynamicPluginsGlobalConfigFile = os.path.join(dynamicPluginsRoot, 'app-config.dynamic-plugins.yaml')
# test if file dynamic-plugins.yaml exists
if not os.path.isfile(dynamicPluginsFile):
print(f"No {dynamicPluginsFile} file found. Skipping dynamic plugins installation.")
with open(dynamicPluginsGlobalConfigFile, 'w') as file:
file.write('')
file.close()
exit(0)
globalConfig = {
'dynamicPlugins': {
'rootDirectory': 'dynamic-plugins-root'
}
}
with open(dynamicPluginsFile, 'r') as file:
content = yaml.safe_load(file)
if content == '' or content is None:
print(f"{dynamicPluginsFile} file is empty. Skipping dynamic plugins installation.")
with open(dynamicPluginsGlobalConfigFile, 'w') as file:
file.write('')
file.close()
exit(0)
if not isinstance(content, dict):
raise InstallException(f"{dynamicPluginsFile} content must be a YAML object")
allPlugins = {}
if skipIntegrityCheck:
print(f"SKIP_INTEGRITY_CHECK has been set to {skipIntegrityCheck}, skipping integrity check of packages")
if 'includes' in content:
includes = content['includes']
else:
includes = []
if not isinstance(includes, list):
raise InstallException(f"content of the \'includes\' field must be a list in {dynamicPluginsFile}")
for include in includes:
if not isinstance(include, str):
raise InstallException(f"content of the \'includes\' field must be a list of strings in {dynamicPluginsFile}")
print('\n======= Including dynamic plugins from', include, flush=True)
if not os.path.isfile(include):
raise InstallException(f"File {include} does not exist")
with open(include, 'r') as file:
includeContent = yaml.safe_load(file)
if not isinstance(includeContent, dict):
raise InstallException(f"{include} content must be a YAML object")
includePlugins = includeContent['plugins']
if not isinstance(includePlugins, list):
raise InstallException(f"content of the \'plugins\' field must be a list in {include}")
for plugin in includePlugins:
allPlugins[plugin['package']] = plugin
if 'plugins' in content:
plugins = content['plugins']
else:
plugins = []
if not isinstance(plugins, list):
raise InstallException(f"content of the \'plugins\' field must be a list in {dynamicPluginsFile}")
for plugin in plugins:
package = plugin['package']
if not isinstance(package, str):
raise InstallException(f"content of the \'plugins.package\' field must be a string in {dynamicPluginsFile}")
# if `package` already exists in `allPlugins`, then override its fields
if package not in allPlugins:
allPlugins[package] = plugin
continue
# override the included plugins with fields in the main plugins list
print('\n======= Overriding dynamic plugin configuration', package, flush=True)
for key in plugin:
if key == 'package':
continue
allPlugins[package][key] = plugin[key]
# iterate through the list of plugins
for plugin in allPlugins.values():
package = plugin['package']
if 'disabled' in plugin and plugin['disabled'] is True:
print('\n======= Skipping disabled dynamic plugin', package, flush=True)
continue
print('\n======= Installing dynamic plugin', package, flush=True)
package_is_local = package.startswith('./')
# If package is not local, then integrity check is mandatory
if not package_is_local and not skipIntegrityCheck and not 'integrity' in plugin:
raise InstallException(f"No integrity hash provided for Package {package}")
if package_is_local:
package = os.path.join(os.getcwd(), package[2:])
print('\t==> Grabbing package archive through `npm pack`', flush=True)
completed = subprocess.run(['npm', 'pack', package], capture_output=True, cwd=dynamicPluginsRoot)
if completed.returncode != 0:
raise InstallException(f'Error while installing plugin { package } with \'npm pack\' : ' + completed.stderr.decode('utf-8'))
archive = os.path.join(dynamicPluginsRoot, completed.stdout.decode('utf-8').strip())
if not (package_is_local or skipIntegrityCheck):
print('\t==> Verifying package integrity', flush=True)
verify_package_integrity(plugin, archive, dynamicPluginsRoot)
directory = archive.replace('.tgz', '')
directoryRealpath = os.path.realpath(directory)
print('\t==> Removing previous plugin directory', directory, flush=True)
shutil.rmtree(directory, ignore_errors=True, onerror=None)
os.mkdir(directory)
print('\t==> Extracting package archive', archive, flush=True)
file = tarfile.open(archive, 'r:gz') # NOSONAR
# extract the archive content but take care of zip bombs
for member in file.getmembers():
if member.isreg():
if not member.name.startswith('package/'):
raise InstallException("NPM package archive archive does not start with 'package/' as it should: " + member.name)
if member.size > maxEntrySize:
raise InstallException('Zip bomb detected in ' + member.name)
member.name = member.name.removeprefix('package/')
file.extract(member, path=directory, filter='tar')
elif member.isdir():
print('\t\tSkipping directory entry', member.name, flush=True)
elif member.islnk() or member.issym():
if not member.linkpath.startswith('package/'):
raise InstallException('NPM package archive contains a link outside of the archive: ' + member.name + ' -> ' + member.linkpath)
member.name = member.name.removeprefix('package/')
member.linkpath = member.linkpath.removeprefix('package/')
realpath = os.path.realpath(os.path.join(directory, *os.path.split(member.linkname)))
if not realpath.startswith(directoryRealpath):
raise InstallException('NPM package archive contains a link outside of the archive: ' + member.name + ' -> ' + member.linkpath)
file.extract(member, path=directory, filter='tar')
else:
if member.type == tarfile.CHRTYPE:
type_str = "character device"
elif member.type == tarfile.BLKTYPE:
type_str = "block device"
elif member.type == tarfile.FIFOTYPE:
type_str = "FIFO"
else:
type_str = "unknown"
raise InstallException('NPM package archive contains a non regular file: ' + member.name + ' - ' + type_str)
file.close()
print('\t==> Removing package archive', archive, flush=True)
os.remove(archive)
if 'pluginConfig' not in plugin:
print('\t==> Successfully installed dynamic plugin', package, flush=True)
continue
# if some plugin configuration is defined, merge it with the global configuration
print('\t==> Merging plugin-specific configuration', flush=True)
config = plugin['pluginConfig']
if config is not None and isinstance(config, dict):
merge(config, globalConfig)
print('\t==> Successfully installed dynamic plugin', package, flush=True)
yaml.safe_dump(globalConfig, open(dynamicPluginsGlobalConfigFile, 'w'))
main()