305 lines
13 KiB
Python
Executable File
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()
|