diff --git a/docker/install-dynamic-plugins.py b/docker/install-dynamic-plugins.py index 244b48e914..26523b501e 100755 --- a/docker/install-dynamic-plugins.py +++ b/docker/install-dynamic-plugins.py @@ -40,6 +40,7 @@ Environment Variables: MAX_ENTRY_SIZE: Maximum size of a file in the archive (default: 20MB) SKIP_INTEGRITY_CHECK: Set to "true" to skip integrity check of remote packages + CATALOG_INDEX_IMAGE: OCI image reference for the plugin catalog index (e.g., quay.io/rhdh/plugin-catalog-index:1.9) Configuration: The script 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. @@ -101,6 +102,8 @@ class InstallException(Exception): 'blake3', ) +DOCKER_PROTOCOL_PREFIX = 'docker://' + def merge(source, destination, prefix = ''): for key, value in source.items(): if isinstance(value, dict): @@ -418,6 +421,7 @@ def merge_plugin(self, level: int): self.allPlugins[pluginKey]["last_modified_level"] = level self.override_plugin(version, inheritVersion, pluginKey) + class OciDownloader: """Helper class for downloading and extracting plugins from OCI container images.""" @@ -445,7 +449,7 @@ def get_plugin_tar(self, image: str) -> str: image_digest = hashlib.sha256(image.encode('utf-8'), usedforsecurity=False).hexdigest() local_dir = os.path.join(self.tmp_dir, image_digest) # replace oci:// prefix with docker:// - image_url = image.replace('oci://', 'docker://') + image_url = image.replace('oci://', DOCKER_PROTOCOL_PREFIX) self.skopeo(['copy', image_url, f'dir:{local_dir}']) manifest_path = os.path.join(local_dir, 'manifest.json') manifest = json.load(open(manifest_path)) @@ -458,7 +462,7 @@ def get_plugin_tar(self, image: str) -> str: return self.image_to_tarball[image] def extract_plugin(self, tar_file: str, plugin_path: str) -> None: - with tarfile.open(tar_file, 'r:gz') as tar: # NOSONAR + with tarfile.open(tar_file, 'r:*') as tar: # NOSONAR # extract only the files in specified directory filesToExtract = [] for member in tar.getmembers(): @@ -490,7 +494,7 @@ def download(self, package: str) -> str: def digest(self, package: str) -> str: (image, _) = package.split('!') - image_url = image.replace('oci://', 'docker://') + image_url = image.replace('oci://', DOCKER_PROTOCOL_PREFIX) output = self.skopeo(['inspect', image_url]) data = json.loads(output) # OCI artifact digest field is defined as "hash method" ":" "hash" @@ -542,7 +546,9 @@ def install(self, plugin: dict, plugin_path_by_hash: dict) -> str: plugin_path = self.downloader.download(package) # Save digest for future comparison - digest_file_path = os.path.join(self.destination, plugin_path, 'dynamic-plugin-image.hash') + plugin_directory = os.path.join(self.destination, plugin_path) + os.makedirs(plugin_directory, exist_ok=True) # Ensure directory exists + digest_file_path = os.path.join(plugin_directory, 'dynamic-plugin-image.hash') with open(digest_file_path, 'w') as f: f.write(self.downloader.digest(package)) @@ -604,7 +610,7 @@ def _extract_npm_package(self, archive: str) -> str: os.mkdir(directory) print('\t==> Extracting package archive', archive, flush=True) - with tarfile.open(archive, 'r:gz') as tar: + with tarfile.open(archive, 'r:*') as tar: # NOSONAR for member in tar.getmembers(): if member.isreg(): if not member.name.startswith('package/'): @@ -614,7 +620,7 @@ def _extract_npm_package(self, archive: str) -> str: raise InstallException(f'Zip bomb detected in {member.name}') member.name = member.name.removeprefix('package/') - tar.extract(member, path=directory, filter='tar') + tar.extract(member, path=directory, filter='data') elif member.isdir(): print('\t\tSkipping directory entry', member.name, flush=True) @@ -630,7 +636,7 @@ def _extract_npm_package(self, archive: str) -> str: if not realpath.startswith(directory_realpath): raise InstallException(f'NPM package archive contains a link outside of the archive: {member.name} -> {member.linkpath}') - tar.extract(member, path=directory, filter='tar') + tar.extract(member, path=directory, filter='data') else: type_mapping = { @@ -789,15 +795,105 @@ def wait_for_lock_release(lock_file_path): time.sleep(1) print("======= Lock released.") +# Clean up temporary catalog index directory +def cleanup_catalog_index_temp_dir(dynamic_plugins_root): + """Clean up temporary catalog index directory.""" + catalog_index_temp_dir = os.path.join(dynamic_plugins_root, '.catalog-index-temp') + if os.path.exists(catalog_index_temp_dir): + print('\n======= Cleaning up temporary catalog index directory', flush=True) + shutil.rmtree(catalog_index_temp_dir, ignore_errors=True, onerror=None) + +def _extract_catalog_index_layers(manifest: dict, local_dir: str, catalog_index_temp_dir: str) -> None: + """Extract layers from the catalog index OCI image.""" + max_entry_size = int(os.environ.get('MAX_ENTRY_SIZE', 20000000)) + + for layer in manifest.get('layers', []): + layer_digest = layer.get('digest', '') + if not layer_digest: + continue + + (_sha, filename) = layer_digest.split(':') + layer_file = os.path.join(local_dir, filename) + if not os.path.isfile(layer_file): + print(f"\t==> WARNING: Layer file {filename} not found", flush=True) + continue + + print(f"\t==> Extracting layer {filename}", flush=True) + _extract_layer_tarball(layer_file, catalog_index_temp_dir, max_entry_size) + +def _extract_layer_tarball(layer_file: str, catalog_index_temp_dir: str, max_entry_size: int) -> None: + """Extract a single layer tarball with security checks.""" + with tarfile.open(layer_file, 'r:*') as tar: # NOSONAR + for member in tar.getmembers(): + # Security checks + if member.size > max_entry_size: + print(f"\t==> WARNING: Skipping large file {member.name} in catalog index", flush=True) + continue + if member.islnk() or member.issym(): + realpath = os.path.realpath(os.path.join(catalog_index_temp_dir, *os.path.split(member.linkname))) + if not realpath.startswith(catalog_index_temp_dir): + print(f"\t==> WARNING: Skipping link outside archive: {member.name}", flush=True) + continue + tar.extract(member, path=catalog_index_temp_dir, filter='data') + +def extract_catalog_index(catalog_index_image: str, catalog_index_mount: str) -> str: + """Extract the catalog index OCI image and return the path to dynamic-plugins.default.yaml if found.""" + print(f"\n======= Extracting catalog index from {catalog_index_image}", flush=True) + skopeo_path = shutil.which('skopeo') + if skopeo_path is None: + raise InstallException("CATALOG_INDEX_IMAGE is set but skopeo executable not found in PATH. Cannot extract catalog index.") + + catalog_index_temp_dir = os.path.join(catalog_index_mount, '.catalog-index-temp') + os.makedirs(catalog_index_temp_dir, exist_ok=True) + + with tempfile.TemporaryDirectory() as tmp_dir: + image_url = catalog_index_image + if not image_url.startswith(DOCKER_PROTOCOL_PREFIX): + image_url = f'{DOCKER_PROTOCOL_PREFIX}{image_url}' + print("\t==> Copying catalog index image to local filesystem", flush=True) + local_dir = os.path.join(tmp_dir, 'catalog-index-oci') + + # Download the OCI image using skopeo + result = subprocess.run( + [skopeo_path, 'copy', image_url, f'dir:{local_dir}'], + capture_output=True, + text=True + ) + if result.returncode != 0: + raise InstallException(f"Failed to download catalog index image {catalog_index_image}: {result.stderr}") + + manifest_path = os.path.join(local_dir, 'manifest.json') + if not os.path.isfile(manifest_path): + raise InstallException(f"manifest.json not found in catalog index image {catalog_index_image}") + + with open(manifest_path, 'r') as f: + manifest = json.load(f) + + print("\t==> Extracting catalog index layers", flush=True) + _extract_catalog_index_layers(manifest, local_dir, catalog_index_temp_dir) + + default_plugins_file = os.path.join(catalog_index_temp_dir, 'dynamic-plugins.default.yaml') + if not os.path.isfile(default_plugins_file): + raise InstallException(f"Catalog index image {catalog_index_image} does not contain the expected dynamic-plugins.default.yaml file") + print("\t==> Successfully extracted catalog index with dynamic-plugins.default.yaml", flush=True) + return default_plugins_file + def main(): dynamicPluginsRoot = sys.argv[1] lock_file_path = os.path.join(dynamicPluginsRoot, 'install-dynamic-plugins.lock') atexit.register(remove_lock, lock_file_path) + atexit.register(cleanup_catalog_index_temp_dir, dynamicPluginsRoot) signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(0)) create_lock(lock_file_path) + # Extract catalog index if CATALOG_INDEX_IMAGE is set + catalog_index_image = os.environ.get("CATALOG_INDEX_IMAGE", "") + catalog_index_default_file = None + if catalog_index_image: + catalog_index_default_file = extract_catalog_index(catalog_index_image, dynamicPluginsRoot) + skipIntegrityCheck = os.environ.get("SKIP_INTEGRITY_CHECK", "").lower() == "true" dynamicPluginsFile = 'dynamic-plugins.yaml' @@ -843,6 +939,15 @@ def main(): if not isinstance(includes, list): raise InstallException(f"content of the \'includes\' field must be a list in {dynamicPluginsFile}") + # Replace dynamic-plugins.default.yaml with catalog index if it was extracted + if catalog_index_image: + embedded_default = 'dynamic-plugins.default.yaml' + if embedded_default in includes: + print(f"\n======= Replacing {embedded_default} with catalog index: {catalog_index_default_file}", flush=True) + # Replace the embedded default file with the catalog index at the same position + index = includes.index(embedded_default) + includes[index] = catalog_index_default_file + 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}") diff --git a/docker/test_install-dynamic-plugins.py b/docker/test_install-dynamic-plugins.py index fde7be4b66..4d69a0ab0a 100644 --- a/docker/test_install-dynamic-plugins.py +++ b/docker/test_install-dynamic-plugins.py @@ -20,6 +20,7 @@ - OciPackageMerger.parse_plugin_key() - Parsing OCI package formats - NPMPackageMerger.merge_plugin() - Plugin config merging and override logic - OciPackageMerger.merge_plugin() - OCI plugin merging with version inheritance +- extract_catalog_index() - Extracting plugin catalog index from OCI images Installation: To install test dependencies: @@ -57,6 +58,44 @@ OciPackageMerger = install_dynamic_plugins.OciPackageMerger InstallException = install_dynamic_plugins.InstallException +# Test helper functions +import tarfile # noqa: E402 + +def create_test_tarball(tarball_path, mode='w:gz'): # noqa: S202 + """ + Helper function to create test tarballs. + + Note: This is safe for test code as we're creating controlled test data, + not opening untrusted archives. The noqa: S202 suppresses security warnings + about tarfile usage which are not applicable to test fixtures. + """ + return tarfile.open(tarball_path, mode) # NOSONAR + +def create_mock_skopeo_copy(manifest_path, layer_tarball, mock_result): + """ + Helper function to create mock subprocess.run for skopeo copy operations. + + Args: + manifest_path: Path to manifest.json file to copy + layer_tarball: Path to layer tarball file to copy + mock_result: Mock result object to return + + Returns: + A function that can be used as side_effect for subprocess.run mock + """ + def mock_subprocess_run(cmd, **kwargs): + if 'copy' in cmd: + dest_arg = [arg for arg in cmd if arg.startswith('dir:')] + if dest_arg: + dest_dir = dest_arg[0].replace('dir:', '') + os.makedirs(dest_dir, exist_ok=True) + import shutil as sh + sh.copy(str(manifest_path), dest_dir) + sh.copy(str(layer_tarball), dest_dir) + return mock_result + + return mock_subprocess_run + class TestNPMPackageMergerParsePluginKey: """Test cases for NPMPackageMerger.parse_plugin_key() method.""" @@ -913,7 +952,7 @@ def test_verify_package_integrity_with_real_tarball(self, tmp_path): (test_dir / "index.js").write_text("console.log('test');") tarball_path = tmp_path / "test-package.tgz" - with tarfile.open(tarball_path, "w:gz") as tar: + with create_test_tarball(tarball_path) as tar: tar.add(test_dir, arcname="package") # Calculate actual integrity hash using openssl @@ -966,7 +1005,7 @@ def test_extract_npm_package_with_real_tarball(self, tmp_path): # Create tarball following NPM format (with 'package/' prefix) tarball_path = tmp_path / "test-package-1.0.0.tgz" - with tarfile.open(tarball_path, "w:gz") as tar: + with create_test_tarball(tarball_path) as tar: tar.add(package_dir, arcname="package") # Test extraction @@ -996,7 +1035,7 @@ def test_zip_bomb_protection_real_tarball(self, tmp_path): (package_dir / "huge-file.bin").write_bytes(large_content) tarball_path = tmp_path / "malicious.tgz" - with tarfile.open(tarball_path, "w:gz") as tar: + with create_test_tarball(tarball_path) as tar: tar.add(package_dir / "huge-file.bin", arcname="package/huge-file.bin") installer = install_dynamic_plugins.NpmPluginInstaller(str(tmp_path)) @@ -1014,7 +1053,7 @@ def test_path_traversal_protection_real_tarball(self, tmp_path): # Create tarball with path traversal attempt tarball_path = tmp_path / "malicious.tgz" - with tarfile.open(tarball_path, "w:gz") as tar: + with create_test_tarball(tarball_path) as tar: # Create a TarInfo with malicious path info = tarfile.TarInfo(name="test") info.size = 10 @@ -1035,7 +1074,7 @@ def test_symlink_with_invalid_linkpath_prefix(self, tmp_path): # Create tarball with a symlink that has invalid linkpath prefix tarball_path = tmp_path / "malicious.tgz" - with tarfile.open(tarball_path, "w:gz") as tar: + with create_test_tarball(tarball_path) as tar: # First add a regular file info = tarfile.TarInfo(name="package/index.js") info.size = 10 @@ -1063,7 +1102,7 @@ def test_symlink_resolving_outside_directory(self, tmp_path): # Create tarball with a symlink that resolves outside the extraction directory tarball_path = tmp_path / "malicious.tgz" - with tarfile.open(tarball_path, "w:gz") as tar: + with create_test_tarball(tarball_path) as tar: # Add a regular file info = tarfile.TarInfo(name="package/index.js") info.size = 10 @@ -1091,7 +1130,7 @@ def test_hardlink_resolving_outside_directory(self, tmp_path): # Create tarball with a hardlink that resolves outside the extraction directory tarball_path = tmp_path / "malicious.tgz" - with tarfile.open(tarball_path, "w:gz") as tar: + with create_test_tarball(tarball_path) as tar: # Add a regular file info = tarfile.TarInfo(name="package/index.js") info.size = 10 @@ -1118,7 +1157,7 @@ def test_valid_symlink_extraction(self, tmp_path): # Create tarball with valid internal symlinks tarball_path = tmp_path / "valid-package.tgz" - with tarfile.open(tarball_path, "w:gz") as tar: + with create_test_tarball(tarball_path) as tar: # Add a regular file info = tarfile.TarInfo(name="package/lib/helper.js") content = b"module.exports = { helper: () => {} };" @@ -1240,7 +1279,7 @@ def test_extract_plugin_with_valid_path(self, tmp_path, mocker): plugin_path = "internal-backstage-plugin-test" tarball_path = tmp_path / "test.tar.gz" - with tarfile.open(tarball_path, "w:gz") as tar: + with create_test_tarball(tarball_path) as tar: # Add plugin files for filename in ["package.json", "index.js"]: info = tarfile.TarInfo(name=f"{plugin_path}/{filename}") @@ -1270,7 +1309,7 @@ def test_extract_plugin_rejects_oversized_files(self, tmp_path, mocker): # Create tarball with oversized file (needs actual content matching size) large_content = b"x" * 25_000_000 # 25MB, exceeds default 20MB - with tarfile.open(tarball_path, "w:gz") as tar: + with create_test_tarball(tarball_path) as tar: info = tarfile.TarInfo(name=f"{plugin_path}/huge.bin") info.size = len(large_content) tar.addfile(info, io.BytesIO(large_content)) @@ -1798,6 +1837,257 @@ def test_lock_file_mtime_detection(self, tmp_path): hash2 = hashlib.sha256(json.dumps(info2, sort_keys=True).encode('utf-8')).hexdigest() assert hash1 != hash2 +class TestExtractCatalogIndex: + """Test cases for extract_catalog_index() function.""" + + @pytest.fixture + def mock_oci_image(self, tmp_path): + """Create a mock OCI image structure with manifest and layer.""" + import tarfile + + # Create a temporary directory for the OCI image + oci_dir = tmp_path / "oci-image" + oci_dir.mkdir() + + # Create manifest.json + manifest = { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:test123", + "size": 100 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:abc123def456", + "size": 1000 + } + ] + } + manifest_path = oci_dir / "manifest.json" + manifest_path.write_text(json.dumps(manifest)) + + # Create a layer tarball with dynamic-plugins.default.yaml + layer_content_dir = tmp_path / "layer-content" + layer_content_dir.mkdir() + + yaml_file = layer_content_dir / "dynamic-plugins.default.yaml" + yaml_content = """plugins: + - package: '@backstage/plugin-catalog' + integrity: sha512-test +""" + yaml_file.write_text(yaml_content) + + # Create the layer tarball + layer_tarball = oci_dir / "abc123def456" + with create_test_tarball(layer_tarball) as tar: + tar.add(str(yaml_file), arcname="dynamic-plugins.default.yaml") + + return { + "oci_dir": str(oci_dir), + "manifest_path": str(manifest_path), + "layer_tarball": str(layer_tarball), + "yaml_content": yaml_content + } + + def test_extract_catalog_index_skopeo_not_found(self, tmp_path, mocker): + """Test that function raises InstallException when skopeo is not available.""" + mocker.patch('shutil.which', return_value=None) + + with pytest.raises(install_dynamic_plugins.InstallException, match="skopeo executable not found in PATH"): + install_dynamic_plugins.extract_catalog_index( + "quay.io/test/image:latest", + str(tmp_path) + ) + + def test_extract_catalog_index_skopeo_copy_fails(self, tmp_path, mocker): + """Test that function raises InstallException when skopeo copy fails.""" + mocker.patch('shutil.which', return_value='/usr/bin/skopeo') + + # Mock subprocess.run to simulate skopeo failure + mock_result = mocker.Mock() + mock_result.returncode = 1 + mock_result.stderr = "Error: image not found" + mocker.patch('subprocess.run', return_value=mock_result) + + with pytest.raises(install_dynamic_plugins.InstallException, match="Failed to download catalog index image"): + install_dynamic_plugins.extract_catalog_index( + "quay.io/test/image:latest", + str(tmp_path) + ) + + def test_extract_catalog_index_no_manifest(self, tmp_path, mocker): + """Test that function raises InstallException when manifest.json is not found.""" + mocker.patch('shutil.which', return_value='/usr/bin/skopeo') + + # Mock subprocess.run to simulate successful skopeo copy + mock_result = mocker.Mock() + mock_result.returncode = 0 + mocker.patch('subprocess.run', return_value=mock_result) + + with pytest.raises(install_dynamic_plugins.InstallException, match="manifest.json not found in catalog index image"): + install_dynamic_plugins.extract_catalog_index( + "quay.io/test/image:latest", + str(tmp_path) + ) + + def test_extract_catalog_index_success(self, tmp_path, mocker, mock_oci_image): + """Test successful extraction of catalog index with dynamic-plugins.default.yaml.""" + catalog_mount = tmp_path / "catalog-mount" + catalog_mount.mkdir() + + mocker.patch('shutil.which', return_value='/usr/bin/skopeo') + + # Mock subprocess.run to simulate successful skopeo copy + mock_result = mocker.Mock() + mock_result.returncode = 0 + mock_subprocess_run = create_mock_skopeo_copy( + mock_oci_image['manifest_path'], + mock_oci_image['layer_tarball'], + mock_result + ) + mocker.patch('subprocess.run', side_effect=mock_subprocess_run) + + result = install_dynamic_plugins.extract_catalog_index( + "quay.io/test/catalog-index:1.9", + str(catalog_mount) + ) + + # Verify the function returned a path + assert result is not None + assert result.endswith('dynamic-plugins.default.yaml') + + # Verify the file exists and contains expected content + assert os.path.isfile(result) + with open(result, 'r') as f: + content = f.read() + assert '@backstage/plugin-catalog' in content + + def test_extract_catalog_index_no_yaml_file(self, tmp_path, mocker): + """Test that function returns None when dynamic-plugins.default.yaml is not found in the image.""" + import tarfile + + catalog_mount = tmp_path / "catalog-mount" + catalog_mount.mkdir() + + # Create OCI structure without the YAML file + oci_dir = tmp_path / "oci-no-yaml" + oci_dir.mkdir() + + manifest = { + "schemaVersion": 2, + "layers": [ + { + "digest": "sha256:xyz789", + "size": 500 + } + ] + } + manifest_path = oci_dir / "manifest.json" + manifest_path.write_text(json.dumps(manifest)) + + # Create empty layer tarball + layer_tarball = oci_dir / "xyz789" + with create_test_tarball(layer_tarball) as tar: + # Add a different file + readme = tmp_path / "README.md" + readme.write_text("# Test") + tar.add(str(readme), arcname="README.md") + + mocker.patch('shutil.which', return_value='/usr/bin/skopeo') + + mock_result = mocker.Mock() + mock_result.returncode = 0 + mock_subprocess_run = create_mock_skopeo_copy(manifest_path, layer_tarball, mock_result) + mocker.patch('subprocess.run', side_effect=mock_subprocess_run) + + with pytest.raises(install_dynamic_plugins.InstallException, match="does not contain the expected dynamic-plugins.default.yaml file"): + install_dynamic_plugins.extract_catalog_index( + "quay.io/test/empty-index:latest", + str(catalog_mount) + ) + + def test_extract_catalog_index_large_file_skipped(self, tmp_path, mocker, monkeypatch): + """Test that files larger than MAX_ENTRY_SIZE are skipped during extraction.""" + import tarfile + + catalog_mount = tmp_path / "catalog-mount" + catalog_mount.mkdir() + + # Set a very small MAX_ENTRY_SIZE for testing + monkeypatch.setenv('MAX_ENTRY_SIZE', '1000') + + # Create OCI structure with a "large" file (larger than our test threshold) + oci_dir = tmp_path / "oci-large-file" + oci_dir.mkdir() + + manifest = { + "schemaVersion": 2, + "layers": [ + { + "digest": "sha256:large123", + "size": 10000 + } + ] + } + manifest_path = oci_dir / "manifest.json" + manifest_path.write_text(json.dumps(manifest)) + + # Create layer with files + layer_tarball = oci_dir / "large123" + layer_content_dir = tmp_path / "large-content" + layer_content_dir.mkdir() + + yaml_file = layer_content_dir / "dynamic-plugins.default.yaml" + yaml_file.write_text("plugins: []") + + # Create a "large" file that's bigger than our test threshold of 1000 bytes + large_file = layer_content_dir / "large-file.bin" + large_file.write_text("x" * 2000) # 2KB - larger than our 1000 byte test limit + + with create_test_tarball(layer_tarball) as tar: + # Add YAML with normal size (smaller than 1000 bytes) + tar.add(str(yaml_file), arcname="dynamic-plugins.default.yaml") + + # Add "large" file (2KB, which exceeds our 1000 byte test limit) + tar.add(str(large_file), arcname="large-file.bin") + + mocker.patch('shutil.which', return_value='/usr/bin/skopeo') + + mock_result = mocker.Mock() + mock_result.returncode = 0 + mock_subprocess_run = create_mock_skopeo_copy(manifest_path, layer_tarball, mock_result) + mocker.patch('subprocess.run', side_effect=mock_subprocess_run) + + result = install_dynamic_plugins.extract_catalog_index( + "quay.io/test/large-file-index:latest", + str(catalog_mount) + ) + + # Should still succeed and find the YAML file + assert result is not None + assert os.path.isfile(result) + + # Verify large file was not extracted + catalog_temp_dir = catalog_mount / ".catalog-index-temp" + large_file_path = catalog_temp_dir / "large-file.bin" + assert not large_file_path.exists() + + def test_extract_catalog_index_exception_handling(self, tmp_path, mocker): + """Test that unexpected exceptions during extraction propagate.""" + mocker.patch('shutil.which', return_value='/usr/bin/skopeo') + + # Mock subprocess.run to raise an exception + mocker.patch('subprocess.run', side_effect=Exception("Unexpected error")) + + with pytest.raises(Exception, match="Unexpected error"): + install_dynamic_plugins.extract_catalog_index( + "quay.io/test/image:latest", + str(tmp_path) + ) + if __name__ == '__main__': pytest.main([__file__, '-v']) diff --git a/docs/dynamic-plugins/installing-plugins.md b/docs/dynamic-plugins/installing-plugins.md index 43e9d814ba..e3d654eea4 100644 --- a/docs/dynamic-plugins/installing-plugins.md +++ b/docs/dynamic-plugins/installing-plugins.md @@ -36,6 +36,71 @@ While the plugin's default configuration comes from the `dynamic-plugins.default Note: The plugin's default configuration typically references environment variables, and it is essential to ensure that these variables are set in the Helm chart values or the Operator configuration. +## Using a Catalog Index Image for Default Plugin Configurations + +RHDH supports loading default plugin configurations from an OCI container image. This feature allows you to maintain centralized plugin configurations that can be updated independently of the RHDH container image. + +When the `CATALOG_INDEX_IMAGE` environment variable is set, the `install-dynamic-plugins` init container will: + +1. Download and extract the specified OCI image +2. Look for a `dynamic-plugins.default.yaml` file within the image +3. Use this file as the primary source for default plugin configurations +4. Replace the embedded `dynamic-plugins.default.yaml` if it's present in the `includes` list + +### Configuring the Catalog Index Image + +Set the `CATALOG_INDEX_IMAGE` environment variable in the `install-dynamic-plugins` init container to specify the OCI image containing your plugin catalog: + +```yaml +# Example using RHDH Operator (Kubernetes/OpenShift) +apiVersion: rhdh.redhat.com/v1alpha4 +kind: Backstage +metadata: + name: my-backstage +spec: + application: + extraEnvs: + envs: + - name: CATALOG_INDEX_IMAGE + value: "quay.io/rhdh/plugin-catalog-index:1.9" + containers: ["install-dynamic-plugins"] +``` + +```yaml +# Example using Helm chart values +# Note: Until native support is added to the Helm chart, you need to customize the +# install-dynamic-plugins init container definition to add the CATALOG_INDEX_IMAGE env var. + +# In your custom values.yaml, add the CATALOG_INDEX_IMAGE environment variable: + +upstream: + backstage: + initContainers: + - name: install-dynamic-plugins + # ... other configuration from the chart ... + env: + - name: CATALOG_INDEX_IMAGE + value: "quay.io/rhdh/plugin-catalog-index:1.9" + # ... other environment variables ... +``` + +To update the catalog index, modify the `CATALOG_INDEX_IMAGE` value in your custom values file and run `helm upgrade`. + +### Catalog Index Image Structure + +The catalog index OCI image should contain a `dynamic-plugins.default.yaml` file at the root level with the same structure as the embedded default configuration file: + +```yaml +# Contents of dynamic-plugins.default.yaml in the OCI image +plugins: + - package: '@backstage/plugin-catalog' + disabled: true + pluginConfig: + # ... plugin configuration + - package: oci://quay.io/example/plugin:v1.0!my-plugin + disabled: true +``` + ## Installing External Dynamic Plugins RHDH supports external dynamic plugins, which are plugins not included in the core RHDH distribution. These plugins can be installed or uninstalled without rebuilding the RHDH application; only a restart is required to apply the changes.