diff --git a/.claude/memories/ci-e2e-testing.md b/.claude/memories/ci-e2e-testing.md index 48053c7593..fbf3e60cc9 100644 --- a/.claude/memories/ci-e2e-testing.md +++ b/.claude/memories/ci-e2e-testing.md @@ -486,7 +486,7 @@ brew install gnu-sed - [Cluster Login Script](.ci/pipelines/ocp-cluster-claim-login.sh) - [Test Reporting Script](.ci/pipelines/reporting.sh) - [Environment Variables](.ci/pipelines/env_variables.sh) -- [Dynamic Plugin Installer](scripts/install-dynamic-plugins/install-dynamic-plugins.py) +- [Dynamic Plugin Installer](scripts/install-dynamic-plugins/install-dynamic-plugins.sh) ## Test Configuration Files (Config Maps) diff --git a/.claude/rules/ci-e2e-testing.md b/.claude/rules/ci-e2e-testing.md index 48053c7593..fbf3e60cc9 100644 --- a/.claude/rules/ci-e2e-testing.md +++ b/.claude/rules/ci-e2e-testing.md @@ -486,7 +486,7 @@ brew install gnu-sed - [Cluster Login Script](.ci/pipelines/ocp-cluster-claim-login.sh) - [Test Reporting Script](.ci/pipelines/reporting.sh) - [Environment Variables](.ci/pipelines/env_variables.sh) -- [Dynamic Plugin Installer](scripts/install-dynamic-plugins/install-dynamic-plugins.py) +- [Dynamic Plugin Installer](scripts/install-dynamic-plugins/install-dynamic-plugins.sh) ## Test Configuration Files (Config Maps) diff --git a/.cursor/rules/ci-e2e-testing.mdc b/.cursor/rules/ci-e2e-testing.mdc index d89f8e0ab3..2401196794 100644 --- a/.cursor/rules/ci-e2e-testing.mdc +++ b/.cursor/rules/ci-e2e-testing.mdc @@ -489,7 +489,7 @@ brew install gnu-sed - [Cluster Login Script](.ci/pipelines/ocp-cluster-claim-login.sh) - [Test Reporting Script](.ci/pipelines/reporting.sh) - [Environment Variables](.ci/pipelines/env_variables.sh) -- [Dynamic Plugin Installer](scripts/install-dynamic-plugins/install-dynamic-plugins.py) +- [Dynamic Plugin Installer](scripts/install-dynamic-plugins/install-dynamic-plugins.sh) ## Test Configuration Files (Config Maps) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index b38fe246c1..098a2b347a 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -148,9 +148,9 @@ jobs: if: ${{ steps.check-image.outputs.is_skipped != 'true' }} run: yarn run test --continue --affected - - name: Run Python tests + - name: Run install-dynamic-plugins tests if: ${{ steps.check-image.outputs.is_skipped != 'true' }} - run: pytest scripts/install-dynamic-plugins/test_install-dynamic-plugins.py -v + run: cd scripts/install-dynamic-plugins && yarn install && yarn test - name: Change directory to dynamic-plugins if: ${{ steps.check-image.outputs.is_skipped != 'true' }} diff --git a/.opencode/memories/ci-e2e-testing.md b/.opencode/memories/ci-e2e-testing.md index 48053c7593..fbf3e60cc9 100644 --- a/.opencode/memories/ci-e2e-testing.md +++ b/.opencode/memories/ci-e2e-testing.md @@ -486,7 +486,7 @@ brew install gnu-sed - [Cluster Login Script](.ci/pipelines/ocp-cluster-claim-login.sh) - [Test Reporting Script](.ci/pipelines/reporting.sh) - [Environment Variables](.ci/pipelines/env_variables.sh) -- [Dynamic Plugin Installer](scripts/install-dynamic-plugins/install-dynamic-plugins.py) +- [Dynamic Plugin Installer](scripts/install-dynamic-plugins/install-dynamic-plugins.sh) ## Test Configuration Files (Config Maps) diff --git a/.rulesync/rules/ci-e2e-testing.md b/.rulesync/rules/ci-e2e-testing.md index c9d0d983cc..1fe215b28e 100644 --- a/.rulesync/rules/ci-e2e-testing.md +++ b/.rulesync/rules/ci-e2e-testing.md @@ -492,7 +492,7 @@ brew install gnu-sed - [Cluster Login Script](.ci/pipelines/ocp-cluster-claim-login.sh) - [Test Reporting Script](.ci/pipelines/reporting.sh) - [Environment Variables](.ci/pipelines/env_variables.sh) -- [Dynamic Plugin Installer](scripts/install-dynamic-plugins/install-dynamic-plugins.py) +- [Dynamic Plugin Installer](scripts/install-dynamic-plugins/install-dynamic-plugins.sh) ## Test Configuration Files (Config Maps) diff --git a/build/containerfiles/Containerfile b/build/containerfiles/Containerfile index c1103d4e8f..f2fb351714 100644 --- a/build/containerfiles/Containerfile +++ b/build/containerfiles/Containerfile @@ -266,9 +266,12 @@ COPY --from=build --chown=1001:1001 "$CONTAINER_SOURCE"/ ./ # RHIDP-4220 - make Konflux preflight and EC checks happy - [check-container] Create a directory named /licenses and include all relevant licensing COPY $EXTERNAL_SOURCE_NESTED/LICENSE /licenses/ -# Copy script to gather dynamic plugins; copy embedded dynamic plugins to root folder; fix permissions -COPY $EXTERNAL_SOURCE_NESTED/scripts/install-dynamic-plugins/install-dynamic-plugins.py $EXTERNAL_SOURCE_NESTED/scripts/install-dynamic-plugins/install-dynamic-plugins.sh ./ -RUN chmod -R a+rx ./install-dynamic-plugins.* +# Copy TypeScript installer bundle (yarn install && yarn build in scripts/install-dynamic-plugins) + launcher +COPY $EXTERNAL_SOURCE_NESTED/scripts/install-dynamic-plugins/dist/install-dynamic-plugins.cjs \ + $EXTERNAL_SOURCE_NESTED/scripts/install-dynamic-plugins/install-dynamic-plugins.sh \ + ./ +RUN chmod a+rx ./install-dynamic-plugins.sh ./install-dynamic-plugins.cjs && \ + cd scripts/install-dynamic-plugins; yarn install && yarn build # Fix for https://issues.redhat.com/browse/RHIDP-728 RUN mkdir -p /opt/app-root/src/.npm; chown -R 1001:1001 /opt/app-root/src/.npm diff --git a/docs/dynamic-plugins/installing-plugins.md b/docs/dynamic-plugins/installing-plugins.md index a32ed388ae..228941365c 100644 --- a/docs/dynamic-plugins/installing-plugins.md +++ b/docs/dynamic-plugins/installing-plugins.md @@ -76,7 +76,7 @@ plugins: ### Catalog Entities Extraction -When the `CATALOG_INDEX_IMAGE` is set and the index image contains a `catalog-entities/marketplace` directory, the [`install-dynamic-plugins.py`](../../scripts/install-dynamic-plugins/install-dynamic-plugins.py) will automatically extract these catalog entities to a configurable location. +When the `CATALOG_INDEX_IMAGE` is set and the index image contains a `catalog-entities/marketplace` directory, the [`install-dynamic-plugins`](../../scripts/install-dynamic-plugins/) (TypeScript; from `scripts/install-dynamic-plugins/` run `yarn install && yarn build` to produce `dist/install-dynamic-plugins.cjs`) will automatically extract these catalog entities to a configurable location. The extraction destination is governed by the `CATALOG_ENTITIES_EXTRACT_DIR` environment variable: diff --git a/scripts/install-dynamic-plugins/.gitignore b/scripts/install-dynamic-plugins/.gitignore index cb8e24765a..a4a0d7c2cf 100644 --- a/scripts/install-dynamic-plugins/.gitignore +++ b/scripts/install-dynamic-plugins/.gitignore @@ -6,3 +6,4 @@ pyvenv.cfg **/__pycache__/ **/.pytest_cache/ **/.venv/ +node_modules/ diff --git a/scripts/install-dynamic-plugins/esbuild.config.cjs b/scripts/install-dynamic-plugins/esbuild.config.cjs new file mode 100644 index 0000000000..a86c7a5d9a --- /dev/null +++ b/scripts/install-dynamic-plugins/esbuild.config.cjs @@ -0,0 +1,20 @@ +'use strict'; + +const esbuild = require('esbuild'); +const path = require('path'); + +esbuild + .build({ + entryPoints: [path.join(__dirname, 'dist', 'cli.js')], + bundle: true, + platform: 'node', + target: 'node22', + format: 'cjs', + outfile: path.join(__dirname, 'dist', 'install-dynamic-plugins.cjs'), + banner: { + js: '#!/usr/bin/env node\n' + }, + sourcemap: true, + logLevel: 'info' + }) + .catch(() => process.exit(1)); diff --git a/scripts/install-dynamic-plugins/install-dynamic-plugins.py b/scripts/install-dynamic-plugins/install-dynamic-plugins.py deleted file mode 100755 index 198e48e7d0..0000000000 --- a/scripts/install-dynamic-plugins/install-dynamic-plugins.py +++ /dev/null @@ -1,1288 +0,0 @@ -# -# Copyright 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 copy -from enum import StrEnum -import hashlib -import json -import os -import sys -import tempfile -import yaml -import tarfile -import shutil -import subprocess -import base64 -import binascii -import atexit -import time -import signal -import re - -""" -Dynamic Plugin Installer for Backstage Application - -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. - -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. - - The `dynamic-plugins.yaml` file must contain: - - a `plugins` list of objects with the following properties: - - `package`: the package to install (NPM package name, local path starting with './', or OCI image starting with 'oci://') - - For OCI packages ONLY, the tag or digest can be replaced by the `{{inherit}}` tag (requires the included configuration to contain a valid tag or digest to inherit from) - - If the OCI image contains only a single plugin, the plugin path can be omitted and will be auto-detected from the image metadata (normally specified by !) - - When using `{{inherit}}`, the plugin path can also be omitted to inherit both version and path from a base configuration (only works if exactly one plugin from that image is defined in included files) - - `integrity`: a string containing the integrity hash of the package (required for remote NPM packages unless SKIP_INTEGRITY_CHECK is set, optional for local packages, not used for OCI packages) - - `pluginConfig`: an optional plugin-specific configuration fragment - - `disabled`: an optional boolean to disable the plugin (`false` by default) - - `pullPolicy`: download behavior control - 'IfNotPresent' (default) or 'Always' (OCI packages with ':latest!' default to 'Always') - - `forceDownload`: an optional boolean to force download for NPM packages even if already installed (`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. - - A simple empty example `dynamic-plugins.yaml` file: - - ```yaml - includes: - - dynamic-plugins.default.yaml - plugins: [] - ``` - -Package Types: - 1. NPM packages: Standard package names (e.g., '@backstage/plugin-catalog') - 2. Local packages: Paths starting with './' (e.g., './my-local-plugin') - automatically detects changes via package.json version, modification times, and lock files - 3. OCI packages: Images starting with 'oci://' (e.g., 'oci://quay.io/user/plugin:v1.0!plugin-name') - -Pull Policies: - - IfNotPresent: Only download if not already installed (default for most packages) - - Always: Always check for updates and download if different (default for OCI packages with ':latest!' tag) - -Process: - For each enabled plugin mentioned in the main `plugins` list and the various included files, the script will: - - For NPM packages: call `npm pack` to get the package archive and extract it - - For OCI packages: use `skopeo` to download and extract the specified plugin from the container image - - For local packages: pack and extract from the local filesystem - - Verify package integrity (for remote NPM packages only, unless skipped) - - Track installation state using hash files to detect changes and avoid unnecessary re-downloads - - Merge the plugin-specific configuration fragment in a global configuration file named `app-config.dynamic-plugins.yaml` - -""" - -class PullPolicy(StrEnum): - IF_NOT_PRESENT = 'IfNotPresent' - ALWAYS = 'Always' - # NEVER = 'Never' not needed - -class InstallException(Exception): - """Exception class from which every exception in this library will derive.""" - pass - -# Refer to https://github.com/opencontainers/image-spec/blob/main/descriptor.md#registered-algorithms -RECOGNIZED_ALGORITHMS = ( - 'sha512', - 'sha256', - 'blake3', -) - -DOCKER_PROTOCOL_PREFIX = 'docker://' -OCI_PROTOCOL_PREFIX = 'oci://' -RHDH_REGISTRY_PREFIX = 'registry.access.redhat.com/rhdh/' -RHDH_FALLBACK_PREFIX = 'quay.io/rhdh/' - -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 - -def maybe_merge_config(config, global_config): - if config is not None and isinstance(config, dict): - print('\t==> Merging plugin-specific configuration', flush=True) - return merge(config, global_config) - else: - return global_config - -def merge_plugin(plugin: dict, all_plugins: dict, dynamic_plugins_file: str, level: int): - package = plugin['package'] - if not isinstance(package, str): - raise InstallException(f"content of the \'plugins.package\' field must be a string in {dynamic_plugins_file}") - - if package.startswith(OCI_PROTOCOL_PREFIX): - return OciPackageMerger(plugin, dynamic_plugins_file, all_plugins).merge_plugin(level) - else: - # Use NPMPackageMerger for all other package types (NPM, git, local, tarball, etc.) - return NPMPackageMerger(plugin, dynamic_plugins_file, all_plugins).merge_plugin(level) - -def run_command(command: list[str], error_message: str, cwd: str = None, text: bool = True) -> subprocess.CompletedProcess: - """ - Run a subprocess command with consistent error handling. - - Args: - command: List of command arguments to execute - error_message: Descriptive error message prefix for failures - cwd: Working directory for the command (optional) - text: If True, decode stdout/stderr as text (default: True) - - Returns: - subprocess.CompletedProcess: The result of the command execution - - Raises: - InstallException: If the command fails with detailed error information - """ - try: - return subprocess.run( - command, - check=True, - capture_output=True, - text=text, - cwd=cwd - ) - except subprocess.CalledProcessError as e: - def to_text(output): - return output.strip() if isinstance(output, str) else output.decode('utf-8').strip() - - msg = f"{error_message}: command failed with exit code {e.returncode}" - msg += f"\ncommand: {' '.join(e.cmd)}" - if e.stderr: - msg += f"\nstderr: {to_text(e.stderr)}" - if e.stdout: - msg += f"\nstdout: {to_text(e.stdout)}" - raise InstallException(msg) - -def image_exists_in_registry(image_url: str) -> bool: - """ - Check if an image exists in a registry using skopeo inspect. - - Args: - image_url: The image URL with docker:// protocol prefix - - Returns: - True if the image exists, False otherwise - """ - skopeo_path = shutil.which('skopeo') - if not skopeo_path: - raise InstallException('skopeo executable not found in PATH') - - try: - subprocess.run( - [skopeo_path, 'inspect', '--no-tags', image_url], - check=True, - capture_output=True, - text=True - ) - return True - except subprocess.CalledProcessError: - return False - -def resolve_image_reference(image: str) -> str: - """ - Resolve an image reference, falling back to quay.io/rhdh/ if the image - starts with registry.access.redhat.com/rhdh/ and doesn't exist there. - - Args: - image: The image reference (may start with oci:// or docker:// or just be the image path) - - Returns: - The resolved image reference (either original or with fallback registry) - """ - # Strip protocol prefix to check the actual image path - check_image = image - protocol_prefix = '' - if image.startswith(OCI_PROTOCOL_PREFIX): - check_image = image[len(OCI_PROTOCOL_PREFIX):] - protocol_prefix = OCI_PROTOCOL_PREFIX - elif image.startswith(DOCKER_PROTOCOL_PREFIX): - check_image = image[len(DOCKER_PROTOCOL_PREFIX):] - protocol_prefix = DOCKER_PROTOCOL_PREFIX - - # Only process images from registry.access.redhat.com/rhdh/ - if not check_image.startswith(RHDH_REGISTRY_PREFIX): - return image - - # Construct the docker:// URL for checking - docker_url = f"{DOCKER_PROTOCOL_PREFIX}{check_image}" - - print(f'\t==> Checking if image exists in {RHDH_REGISTRY_PREFIX}...', flush=True) - - if image_exists_in_registry(docker_url): - print(f'\t==> Image found in {RHDH_REGISTRY_PREFIX}', flush=True) - return image - - # Fallback to quay.io/rhdh/ - fallback_image = check_image.replace(RHDH_REGISTRY_PREFIX, RHDH_FALLBACK_PREFIX, 1) - print(f'\t==> Image not found in {RHDH_REGISTRY_PREFIX}, falling back to {RHDH_FALLBACK_PREFIX}', flush=True) - print(f'\t==> Using fallback image: {fallback_image}', flush=True) - - return f"{protocol_prefix}{fallback_image}" - -def get_oci_plugin_paths(image: str) -> list[str]: - """ - Get list of plugin paths from OCI image via manifest annotation. - - Args: - image: OCI image reference (e.g., 'oci://registry/path:tag') - - Returns: - List of plugin paths from the manifest annotation - """ - skopeo_path = shutil.which('skopeo') - if not skopeo_path: - raise InstallException('skopeo executable not found in PATH') - - # Resolve image reference with fallback if needed - resolved_image = resolve_image_reference(image) - image_url = resolved_image.replace(OCI_PROTOCOL_PREFIX, DOCKER_PROTOCOL_PREFIX) - result = run_command( - [skopeo_path, 'inspect', '--no-tags', '--raw', image_url], - f"Failed to inspect OCI image {image}" - ) - - try: - manifest = json.loads(result.stdout) - annotations = manifest.get('annotations', {}) - annotation_value = annotations.get('io.backstage.dynamic-packages') - - if not annotation_value: - return [] - - decoded = base64.b64decode(annotation_value).decode('utf-8') - plugins_metadata = json.loads(decoded) - except Exception as e: - raise InstallException(f"Failed to parse plugin metadata from {image}: {e}") - - plugin_paths = [] - for plugin_obj in plugins_metadata: - if isinstance(plugin_obj, dict): - plugin_paths.extend(plugin_obj.keys()) - - return plugin_paths - -class PackageMerger: - def __init__(self, plugin: dict, dynamic_plugins_file: str, all_plugins: dict): - self.plugin = plugin - self.dynamic_plugins_file = dynamic_plugins_file - self.all_plugins = all_plugins - - def parse_plugin_key(self, package: str) -> str: - """Parses the package and returns the plugin key. Must be implemented by subclasses.""" - return package - - def add_new_plugin(self, _version: str, _inherit_version: bool, plugin_key: str): - """Adds a new plugin to the all_plugins dict.""" - self.all_plugins[plugin_key] = self.plugin - def override_plugin(self, _version: str, _inherit_version: bool, plugin_key: str): - """Overrides an existing plugin config with a new plugin config in the all_plugins dict.""" - for key in self.plugin: - self.all_plugins[plugin_key][key] = self.plugin[key] - def merge_plugin(self, level: int): - plugin_key = self.plugin['package'] - if not isinstance(plugin_key, str): - raise InstallException(f"content of the \'package\' field must be a string in {self.dynamic_plugins_file}") - plugin_key = self.parse_plugin_key(plugin_key) - - if plugin_key not in self.all_plugins: - print(f'\n======= Adding new dynamic plugin configuration for {plugin_key}', flush=True) - # Keep track of the level of the plugin modification to know when dupe conflicts occur in `includes` and main config files - self.plugin["last_modified_level"] = level - self.add_new_plugin("", False, plugin_key) - else: - # Override the included plugins with fields in the main plugins list - print('\n======= Overriding dynamic plugin configuration', plugin_key, flush=True) - - # Check for duplicate plugin configurations defined at the same level (level = 0 for `includes` and 1 for the main config file) - if self.all_plugins[plugin_key].get("last_modified_level") == level: - raise InstallException(f"Duplicate plugin configuration for {self.plugin['package']} found in {self.dynamic_plugins_file}.") - - self.all_plugins[plugin_key]["last_modified_level"] = level - self.override_plugin("", False, plugin_key) - -class NPMPackageMerger(PackageMerger): - """Handles NPM package merging with version stripping for plugin keys.""" - # Ref: https://docs.npmjs.com/cli/v11/using-npm/package-spec - # Pattern for standard NPM packages: [@scope/]package[@version|@tag|@version-range|] or [@scope/]package - # Pattern for standard NPM packages: [@scope/]package[@version|@tag|@version-range|] or [@scope/]package - NPM_PACKAGE_PATTERN = ( - r'(@[^/]+/)?' # Optional @scope - r'([^@]+)' # Package name - r'(?:@(.+))?' # Optional @version, @tag, or @version-range - r'$' - ) - - STANDARD_NPM_PACKAGE_PATTERN = r'^' + NPM_PACKAGE_PATTERN - - # Pattern for NPM aliases: alias@npm:[@scope/]package[@version|@tag] - NPM_ALIAS_PATTERN = r'^([^@]+)@npm:' + NPM_PACKAGE_PATTERN - - GITHUB_USERNAME_PATTERN = r'([^/@]+)/([^/#]+)' # username/repo - - # Pattern for git URLs to strip out the #ref part for the plugin key - GIT_URL_PATTERNS = [ - # git+https://...[#ref] - ( - r'^git\+https?://[^#]+' # git+http(s):// - r'(?:#(.+))?' # Optional #ref - r'$' - ), - # git+ssh://...[#ref] - ( - r'^git\+ssh://[^#]+' - r'(?:#(.+))?' - r'$' - ), - # git://...[#ref] - ( - r'^git://[^#]+' - r'(?:#(.+))?' - r'$' - ), - # https://github.com/user/repo(.git)?[#ref] - ( - r'^https://github\.com/[^/]+/[^/#]+' - r'(?:\.git)?' - r'(?:#(.+))?' - r'$' - ), - # git@github.com:user/repo(.git)?[#ref] - ( - r'^git@github\.com:[^/]+/[^/#]+' - r'(?:\.git)?' - r'(?:#(.+))?' - r'$' - ), - # github:user/repo[#ref] - ( - r'^github:' + GITHUB_USERNAME_PATTERN + - r'(?:#(.+))?' + - r'$' - ), - # user/repo[#ref] - ( - r'^' + GITHUB_USERNAME_PATTERN + - r'(?:#(.+))?' + - r'$' - ) - ] - - def __init__(self, plugin: dict, dynamic_plugins_file: str, all_plugins: dict): - super().__init__(plugin, dynamic_plugins_file, all_plugins) - - def parse_plugin_key(self, package: str) -> str: - """ - Parses NPM package specification and returns a version-stripped plugin key. - - Handles various NPM package formats specified in https://docs.npmjs.com/cli/v11/using-npm/package-spec: - - Standard packages: [@scope/]package[@version] -> [@scope/]package - - Aliases: alias@npm:package[@version] -> alias@npm:package - - Git URLs: git+https://... -> git+https://... (without #ref) - - GitHub shorthand: user/repo#ref -> user/repo - - Local paths: ./path -> ./path (unchanged) - - Tarballs: kept as-is since there is no standard format for them - """ - - # Local packages don't need version stripping - if package.startswith('./'): - return package - - # Tarballs are kept as-is since there is no standard format for them - if package.endswith('.tgz'): - return package - - # remove @version from NPM aliases: alias@npm:package[@version] - alias_match = re.match(self.NPM_ALIAS_PATTERN, package) - if alias_match: - alias_name = alias_match.group(1) - package_scope = alias_match.group(2) or '' - npm_package = alias_match.group(3) - # Recursively parse the npm package part to strip its version - npm_key = self._strip_npm_package_version(package_scope + npm_package) - return f"{alias_name}@npm:{npm_key}" - - # Check for git URLs - for git_pattern in self.GIT_URL_PATTERNS: - - git_match = re.match(git_pattern, package) - - if git_match: - # Remove the #ref part if present - return package.split('#')[0] - # Handle standard NPM packages - return self._strip_npm_package_version(package) - - def _strip_npm_package_version(self, package: str) -> str: - """Strip version from standard NPM package name.""" - npm_match = re.match(self.STANDARD_NPM_PACKAGE_PATTERN, package) - if npm_match: - scope = npm_match.group(1) or '' - pkg_name = npm_match.group(2) - return f"{scope}{pkg_name}" - - # If no pattern matches, return as-is (could be tarball URL or other format) - return package - -class PluginInstaller: - """Base class for plugin installers with common functionality.""" - - def __init__(self, destination: str, skip_integrity_check: bool = False): - self.destination = destination - self.skip_integrity_check = skip_integrity_check - - def should_skip_installation(self, plugin: dict, plugin_path_by_hash: dict) -> tuple[bool, str]: - """Check if plugin installation should be skipped based on pull policy and current state.""" - plugin_hash = plugin['plugin_hash'] - pull_policy = plugin.get('pullPolicy', PullPolicy.IF_NOT_PRESENT) - force_download = plugin.get('forceDownload', False) - - if plugin_hash not in plugin_path_by_hash: - return False, "not_installed" - - if pull_policy == PullPolicy.ALWAYS or force_download: - return False, "force_download" - - return True, "already_installed" - - def install(self, plugin: dict, plugin_path_by_hash: dict) -> str: - """Install a plugin and return the plugin path. Must be implemented by subclasses.""" - raise NotImplementedError() - -class OciPackageMerger(PackageMerger): - EXPECTED_OCI_PATTERN = ( - r'^(' + OCI_PROTOCOL_PREFIX + - r'[^\s/:@]+' # hostname (e.g. registry.localhost) - r'(?::\d+)?' # optional port (e.g. :5000) - r'(?:/[^\s:@]+)+' # path segments (e.g. /org/plugin), at least one required - r')' - r'(?:' - r':([^\s!@:]+)' # tag only - r'|' - r'@((?:sha256|sha512|blake3):[^\s!@:]+)' # digest only - r')' - r'(?:!([^\s]+))?$' # plugin path is optional for single plugin packages - ) - def __init__(self, plugin: dict, dynamic_plugins_file: str, all_plugins: dict): - super().__init__(plugin, dynamic_plugins_file, all_plugins) - def parse_plugin_key(self, package: str) -> tuple[str, str, bool, str]: - """ - Parses and validates OCI package name format. - Generates a plugin key and version from the OCI package name. - Also checks if the {{inherit}} tag is used correctly. - - Args: - package: The OCI package name. - Returns: - plugin_key: plugin key generated from the OCI package name - version: detected tag or digest of the plugin - inherit_version: boolean indicating if the `{{inherit}}` tag is used - resolved_path: the resolved plugin path (either explicit or auto-detected) - """ - match = re.match(self.EXPECTED_OCI_PATTERN, package) - if not match: - raise InstallException(f"oci package \'{package}\' is not in the expected format \'{OCI_PROTOCOL_PREFIX}:\' or \'{OCI_PROTOCOL_PREFIX}@:\' (optionally followed by \'!\') in {self.dynamic_plugins_file} where may include a port (e.g. host:5000/path) and is one of {RECOGNIZED_ALGORITHMS}") - - # Strip away the version (tag or digest) from the package string, resulting in oci://:! - # This helps ensure keys used to identify OCI plugins are independent of the version of the plugin - registry = match.group(1) - tag_version = match.group(2) - digest_version = match.group(3) - - version = tag_version if tag_version else digest_version - - path = match.group(4) - - # {{inherit}} tag indicates that the version should be inherited from the included configuration. Must NOT have a SHA digest included. - inherit_version = (tag_version == "{{inherit}}" and digest_version == None) - - # If {{inherit}} without path, we'll use plugin name with registry as the plugin key - if inherit_version and not path: - # Return None for resolved_path - will be inherited during merge_plugin() - return registry, version, inherit_version, None - - # If path is None, auto-detect from OCI manifest - if not path: - full_image = f"{registry}:{version}" if tag_version else f"{registry}@{version}" - print(f"\n======= No plugin path specified for {full_image}, auto-detecting from OCI manifest", flush=True) - plugin_paths = get_oci_plugin_paths(full_image) - - if len(plugin_paths) == 0: - raise InstallException( - f"No plugins found in OCI image {full_image}." - f"The image might not contain the 'io.backstage.dynamic-packages' annotation." - f"Please ensure this was packaged correctly using the @red-hat-developer-hub/cli plugin package command." - ) - - if len(plugin_paths) > 1: - plugins_list = '\n - '.join(plugin_paths) - raise InstallException( - f"Multiple plugins found in OCI image {full_image}:\n - {plugins_list}\n" - f"Please specify which plugin to install using the syntax: {full_image}!" - ) - - path = plugin_paths[0] - print(f'\n======= Auto-resolving OCI package {full_image} to use plugin path: {path}', flush=True) - - # At this point, path always exists (either explicitly provided or auto-detected) - plugin_key = f"{registry}:!{path}" - - return plugin_key, version, inherit_version, path - def add_new_plugin(self, version: str, inherit_version: bool, plugin_key: str): - """ - Adds a new plugin to the all_plugins dict. - """ - if inherit_version is True: - # Cannot use {{inherit}} for the initial plugin configuration - raise InstallException(f"ERROR: {{{{inherit}}}} tag is set and there is currently no resolved tag or digest for {self.plugin['package']} in {self.dynamic_plugins_file}.") - else: - self.plugin["version"] = version - self.all_plugins[plugin_key] = self.plugin - def override_plugin(self, version: str, inherit_version: bool, plugin_key: str): - """ - Overrides an existing plugin config with a new plugin config in the all_plugins dict. - If `inherit_version` is True, the version of the existing plugin config will be ignored. - """ - if inherit_version is not True: - self.all_plugins[plugin_key]['package'] = self.plugin['package'] # Override package since no version inheritance - - if self.all_plugins[plugin_key]['version'] != version: - print(f"INFO: Overriding version for {plugin_key} from `{self.all_plugins[plugin_key]['version']}` to `{version}`") - - self.all_plugins[plugin_key]["version"] = version - - for key in self.plugin: - if key == 'package': - continue - if key == "version": - continue - self.all_plugins[plugin_key][key] = self.plugin[key] - - def merge_plugin(self, level: int): - package = self.plugin['package'] - if not isinstance(package, str): - raise InstallException(f"content of the \'package\' field must be a string in {self.dynamic_plugins_file}") - plugin_key, version, inherit_version, resolved_path = self.parse_plugin_key(package) - - # Special case: {{inherit}} without explicit path - match on image only - if inherit_version and resolved_path is None: - # plugin_key is the registry (oci://registry/image) when path is omitted - - # Find plugins from same image (ignoring path component) - matches = [key for key in self.all_plugins.keys() - if key.startswith(f"{plugin_key}:!")] - - if len(matches) == 0: - raise InstallException( - f"Cannot use {{{{inherit}}}} for {plugin_key}: no existing plugin configuration found. " - f"Ensure a plugin from this image is defined in an included file with an explicit version." - ) - - if len(matches) > 1: - full_packages = [] - for m in matches: - base_plugin = self.all_plugins[m] - base_version = base_plugin.get('version', '') - formatted = f"{m.split(':!')[0]}:{base_version}!{m.split(':!')[-1]}" - full_packages.append(formatted) - paths_formatted = '\n - '.join(full_packages) - raise InstallException( - f"Cannot use {{{{inherit}}}} for {plugin_key}: multiple plugins from this image are defined in the included files:\n - {paths_formatted}\n" - f"Please specify which plugin configuration to inherit from using: {plugin_key}:{{{{inherit}}}}!" - ) - - # inherit both version AND path from the existing plugin configuration - plugin_key = matches[0] - base_plugin = self.all_plugins[plugin_key] - version = base_plugin['version'] - resolved_path = plugin_key.split(':!')[-1] - - registry_part = plugin_key.split(':!')[0] - self.plugin['package'] = f"{registry_part}:{version}!{resolved_path}" - print(f'\n======= Inheriting version `{version}` and plugin path `{resolved_path}` for {plugin_key}', flush=True) - - # Update package with resolved path if it was auto-detected (package didn't originally contain !path) - elif '!' not in package: - self.plugin['package'] = f"{package}!{resolved_path}" - - # If package does not already exist, add it - if plugin_key not in self.all_plugins: - print(f'\n======= Adding new dynamic plugin configuration for version `{version}` of {plugin_key}', flush=True) - # Keep track of the level of the plugin modification to know when dupe conflicts occur in `includes` and main config files - self.plugin["last_modified_level"] = level - self.add_new_plugin(version, inherit_version, plugin_key) - else: - # Override the included plugins with fields in the main plugins list - print('\n======= Overriding dynamic plugin configuration', plugin_key, flush=True) - - # Check for duplicate plugin configurations defined at the same level (level = 0 for `includes` and 1 for the main config file) - if self.all_plugins[plugin_key].get("last_modified_level") == level: - raise InstallException(f"Duplicate plugin configuration for {self.plugin['package']} found in {self.dynamic_plugins_file}.") - - self.all_plugins[plugin_key]["last_modified_level"] = level - self.override_plugin(version, inherit_version, plugin_key) - -class OciDownloader: - """Helper class for downloading and extracting plugins from OCI container images.""" - - def __init__(self, destination: str): - self._skopeo = shutil.which('skopeo') - if self._skopeo is None: - raise InstallException('skopeo executable not found in PATH') - - self.tmp_dir_obj = tempfile.TemporaryDirectory() - self.tmp_dir = self.tmp_dir_obj.name - self.image_to_tarball = {} - self.destination = destination - self.max_entry_size = int(os.environ.get('MAX_ENTRY_SIZE', 20000000)) - - def skopeo(self, command): - result = run_command([self._skopeo] + command, 'skopeo command failed') - return result.stdout - - def get_plugin_tar(self, image: str) -> str: - if image not in self.image_to_tarball: - # Resolve image reference with fallback if needed - resolved_image = resolve_image_reference(image) - - # run skopeo copy to copy the tar ball to the local filesystem - print(f'\t==> Copying image {resolved_image} to local filesystem', flush=True) - image_digest = hashlib.sha256(resolved_image.encode('utf-8'), usedforsecurity=False).hexdigest() - local_dir = os.path.join(self.tmp_dir, image_digest) - # replace oci:// prefix with docker:// - image_url = resolved_image.replace(OCI_PROTOCOL_PREFIX, DOCKER_PROTOCOL_PREFIX) - self.skopeo(['copy', '--override-os=linux', '--override-arch=amd64', image_url, f'dir:{local_dir}']) - manifest_path = os.path.join(local_dir, 'manifest.json') - manifest = json.load(open(manifest_path)) - # get the first layer of the image - layer = manifest['layers'][0]['digest'] - (_sha, filename) = layer.split(':') - local_path = os.path.join(local_dir, filename) - self.image_to_tarball[image] = local_path - - return self.image_to_tarball[image] - - def extract_plugin(self, tar_file: str, plugin_path: str) -> None: - with tarfile.open(tar_file, 'r:*') as tar: # NOSONAR - # extract only the files in specified directory - files_to_extract = [] - for member in tar.getmembers(): - if not member.name.startswith(plugin_path): - continue - # zip bomb protection - if member.size > self.max_entry_size: - raise InstallException('Zip bomb detected in ' + member.name) - - if member.islnk() or member.issym(): - realpath = os.path.realpath(os.path.join(plugin_path, *os.path.split(member.linkname))) - if not realpath.startswith(plugin_path): - print(f'\t==> WARNING: skipping file containing link outside of the archive: {member.name} -> {member.linkpath}', flush=True) - continue - - files_to_extract.append(member) - tar.extractall(os.path.abspath(self.destination), members=files_to_extract, filter='tar') - - def download(self, package: str) -> str: - # At this point, package always contains ! since parse_plugin_key resolved it - (image, plugin_path) = package.split('!') - - tar_file = self.get_plugin_tar(image) - plugin_directory = os.path.join(self.destination, plugin_path) - if os.path.exists(plugin_directory): - print('\t==> Removing previous plugin directory', plugin_directory, flush=True) - shutil.rmtree(plugin_directory, ignore_errors=True, onerror=None) - self.extract_plugin(tar_file=tar_file, plugin_path=plugin_path) - return plugin_path - - def digest(self, package: str) -> str: - # Extract image reference (before the ! if present) - if '!' in package: - (image, _) = package.split('!') - else: - image = package - - # Resolve image reference with fallback if needed - resolved_image = resolve_image_reference(image) - image_url = resolved_image.replace(OCI_PROTOCOL_PREFIX, DOCKER_PROTOCOL_PREFIX) - output = self.skopeo(['inspect', '--no-tags', image_url]) - data = json.loads(output) - # OCI artifact digest field is defined as "hash method" ":" "hash" - digest = data['Digest'].split(':')[1] - return f"{digest}" - -class OciPluginInstaller(PluginInstaller): - """Handles OCI container-based plugin installation using skopeo.""" - - def __init__(self, destination: str, skip_integrity_check: bool = False): - super().__init__(destination, skip_integrity_check) - self.downloader = OciDownloader(destination) - - def should_skip_installation(self, plugin: dict, plugin_path_by_hash: dict) -> tuple[bool, str]: - """OCI packages have special digest-based checking for ALWAYS pull policy.""" - package = plugin['package'] - plugin_hash = plugin['plugin_hash'] - pull_policy = plugin.get('pullPolicy', PullPolicy.ALWAYS if ':latest!' in package else PullPolicy.IF_NOT_PRESENT) - - if plugin_hash not in plugin_path_by_hash: - return False, "not_installed" - - if pull_policy == PullPolicy.IF_NOT_PRESENT: - return True, "already_installed" - - if pull_policy == PullPolicy.ALWAYS: - # Check if digest has changed - installed_path = plugin_path_by_hash[plugin_hash] - digest_file_path = os.path.join(self.destination, installed_path, 'dynamic-plugin-image.hash') - - local_digest = None - if os.path.isfile(digest_file_path): - with open(digest_file_path, 'r') as f: - local_digest = f.read().strip() - - remote_digest = self.downloader.digest(package) - if remote_digest == local_digest: - return True, "digest_unchanged" - - return False, "force_download" - - def install(self, plugin: dict, plugin_path_by_hash: dict) -> str: - """Install an OCI plugin package.""" - package = plugin['package'] - if plugin.get('version') is None: - raise InstallException(f"Tag or Digest is not set for {package}. Please ensure there is at least one plugin configurations contains a valid tag or digest.") - - try: - plugin_path = self.downloader.download(package) - - # Save digest for future comparison - 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)) - - # Clean up duplicate hashes - for key in [k for k, v in plugin_path_by_hash.items() if v == plugin_path]: - plugin_path_by_hash.pop(key) - - return plugin_path - - except Exception as e: - raise InstallException(f"Error while installing OCI plugin {package}: {e}") - -class NpmPluginInstaller(PluginInstaller): - """Handles NPM and local package installation using npm pack.""" - - def __init__(self, destination: str, skip_integrity_check: bool = False): - super().__init__(destination, skip_integrity_check) - self.max_entry_size = int(os.environ.get('MAX_ENTRY_SIZE', 20000000)) - - def install(self, plugin: dict, plugin_path_by_hash: dict) -> str: - """Install an NPM or local plugin package.""" - package = plugin['package'] - package_is_local = package.startswith('./') - - if package_is_local: - package = os.path.join(os.getcwd(), package[2:]) - - # Verify integrity requirements - if not package_is_local and not self.skip_integrity_check and 'integrity' not in plugin: - raise InstallException(f"No integrity hash provided for Package {package}") - - # Download package - print('\t==> Grabbing package archive through `npm pack`', flush=True) - result = run_command( - ['npm', 'pack', package], - f"Error while installing plugin {package} with 'npm pack'", - cwd=self.destination - ) - - archive = os.path.join(self.destination, result.stdout.strip()) - - # Verify integrity for remote packages - if not (package_is_local or self.skip_integrity_check): - print('\t==> Verifying package integrity', flush=True) - verify_package_integrity(plugin, archive) - - # Extract package - plugin_path = self._extract_npm_package(archive) - - return plugin_path - - def _extract_npm_package(self, archive: str) -> str: - """Extract NPM package archive with security protections.""" - PACKAGE_DIRECTORY_PREFIX = 'package/' - directory = archive.replace('.tgz', '') - directory_realpath = os.path.realpath(directory) - plugin_path = os.path.basename(directory_realpath) - - if os.path.exists(directory): - print('\t==> Removing previous plugin directory', directory, flush=True) - shutil.rmtree(directory, ignore_errors=True) - os.mkdir(directory) - - print('\t==> Extracting package archive', archive, flush=True) - with tarfile.open(archive, 'r:*') as tar: # NOSONAR - for member in tar.getmembers(): - if member.isreg(): - if not member.name.startswith(PACKAGE_DIRECTORY_PREFIX): - raise InstallException(f"NPM package archive does not start with 'package/' as it should: {member.name}") - - if member.size > self.max_entry_size: - raise InstallException(f'Zip bomb detected in {member.name}') - - member.name = member.name.removeprefix(PACKAGE_DIRECTORY_PREFIX) - tar.extract(member, path=directory, filter='data') - - elif member.isdir(): - print('\t\tSkipping directory entry', member.name, flush=True) - - elif member.islnk() or member.issym(): - if not member.linkpath.startswith(PACKAGE_DIRECTORY_PREFIX): - raise InstallException(f'NPM package archive contains a link outside of the archive: {member.name} -> {member.linkpath}') - - member.name = member.name.removeprefix(PACKAGE_DIRECTORY_PREFIX) - member.linkpath = member.linkpath.removeprefix(PACKAGE_DIRECTORY_PREFIX) - - realpath = os.path.realpath(os.path.join(directory, *os.path.split(member.linkname))) - 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='data') - - else: - type_mapping = { - tarfile.CHRTYPE: "character device", - tarfile.BLKTYPE: "block device", - tarfile.FIFOTYPE: "FIFO" - } - type_str = type_mapping.get(member.type, "unknown") - raise InstallException(f'NPM package archive contains a non regular file: {member.name} - {type_str}') - - print('\t==> Removing package archive', archive, flush=True) - os.remove(archive) - - return plugin_path - -def create_plugin_installer(package: str, destination: str, skip_integrity_check: bool = False) -> PluginInstaller: - """Factory function to create appropriate plugin installer based on package type.""" - if package.startswith(OCI_PROTOCOL_PREFIX): - return OciPluginInstaller(destination, skip_integrity_check) - else: - return NpmPluginInstaller(destination, skip_integrity_check) - -def install_plugin(plugin: dict, plugin_path_by_hash: dict, destination: str, skip_integrity_check: bool = False) -> tuple[str, dict]: - """Install a single plugin and handle configuration merging.""" - package = plugin['package'] - - # Check if plugin is disabled - if plugin.get('disabled', False): - print(f'\n======= Skipping disabled dynamic plugin {package}', flush=True) - return None, {} - - # Create appropriate installer - installer = create_plugin_installer(package, destination, skip_integrity_check) - - # Check if installation should be skipped - should_skip, reason = installer.should_skip_installation(plugin, plugin_path_by_hash) - if should_skip: - print(f'\n======= Skipping download of already installed dynamic plugin {package} ({reason})', flush=True) - # Remove from tracking dict so we don't delete it later - if plugin['plugin_hash'] in plugin_path_by_hash: - plugin_path_by_hash.pop(plugin['plugin_hash']) - return None, plugin.get('pluginConfig', {}) - - # Install the plugin - print(f'\n======= Installing dynamic plugin {package}', flush=True) - plugin_path = installer.install(plugin, plugin_path_by_hash) - - # Create hash file for tracking - hash_file_path = os.path.join(destination, plugin_path, 'dynamic-plugin-config.hash') - with open(hash_file_path, 'w') as f: - f.write(plugin['plugin_hash']) - - print(f'\t==> Successfully installed dynamic plugin {package}', flush=True) - - return plugin_path, plugin.get('pluginConfig', {}) - -RECOGNIZED_ALGORITHMS = ( - 'sha512', - 'sha384', - 'sha256', -) - -def get_local_package_info(package_path: str) -> dict: - """Get package information from a local package to include in hash calculation.""" - try: - if package_path.startswith('./'): - abs_package_path = os.path.join(os.getcwd(), package_path[2:]) - else: - abs_package_path = package_path - - package_json_path = os.path.join(abs_package_path, 'package.json') - - if not os.path.isfile(package_json_path): - # If no package.json, fall back to directory modification time - if os.path.isdir(abs_package_path): - mtime = os.path.getmtime(abs_package_path) - return {'_directory_mtime': mtime} - else: - return {'_not_found': True} - - with open(package_json_path, 'r') as f: - package_json = json.load(f) - - # Extract relevant fields that indicate package changes - info = {} - info['_package_json'] = package_json - - # Also include package.json modification time as additional change detection - info['_package_json_mtime'] = os.path.getmtime(package_json_path) - - # Include package-lock.json or yarn.lock modification time if present - for lock_file in ['package-lock.json', 'yarn.lock']: - lock_path = os.path.join(abs_package_path, lock_file) - if os.path.isfile(lock_path): - info[f'_{lock_file}_mtime'] = os.path.getmtime(lock_path) - - return info - - except (json.JSONDecodeError, OSError, IOError) as e: - # If we can't read the package info, include the error in hash - # This ensures we'll try to reinstall if there are permission issues, etc. - return {'_error': str(e)} - -def verify_package_integrity(plugin: dict, archive: 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 = 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') - -# Create the lock file, so that other instances of the script will wait for this one to finish -def create_lock(lock_file_path): - while True: - try: - with open(lock_file_path, 'x'): - print(f"======= Created lock file: {lock_file_path}") - return - except FileExistsError: - wait_for_lock_release(lock_file_path) - -# Remove the lock file -def remove_lock(lock_file_path): - os.remove(lock_file_path) - print(f"======= Removed lock file: {lock_file_path}") - -# Wait for the lock file to be released -def wait_for_lock_release(lock_file_path): - print(f"======= Waiting for lock release (file: {lock_file_path})...", flush=True) - while True: - if not os.path.exists(lock_file_path): - break - 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, catalog_entities_parent_dir: 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.") - - # Resolve image reference with fallback if needed - resolved_image = resolve_image_reference(catalog_index_image) - - 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 = resolved_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 - run_command( - [skopeo_path, 'copy', '--override-os=linux', '--override-arch=amd64', image_url, f'dir:{local_dir}'], - f"Failed to download catalog index image {resolved_image}" - ) - - 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 dynamic-plugins.default.yaml from catalog index image", flush=True) - - print(f"\t==> Extracting extensions catalog entities to {catalog_entities_parent_dir}", flush=True) - - extensions_dir_from_catalog_index = os.path.join(catalog_index_temp_dir, 'catalog-entities', 'extensions') - if not os.path.isdir(extensions_dir_from_catalog_index): - # fallback to 'catalog-entities/marketplace' directory for backward compatibility - extensions_dir_from_catalog_index = os.path.join(catalog_index_temp_dir, 'catalog-entities', 'marketplace') - - if os.path.isdir(extensions_dir_from_catalog_index): - os.makedirs(catalog_entities_parent_dir, exist_ok=True) - catalog_entities_dest = os.path.join(catalog_entities_parent_dir, 'catalog-entities') - # Ensure the destination directory is is sync with the catalog entities from the index image - if os.path.exists(catalog_entities_dest): - shutil.rmtree(catalog_entities_dest, ignore_errors=True, onerror=None) - shutil.copytree(extensions_dir_from_catalog_index, catalog_entities_dest, dirs_exist_ok=True) - print("\t==> Successfully extracted extensions catalog entities from index image", flush=True) - else: - print(f"\t==> WARNING: Catalog index image {catalog_index_image} does not have neither 'catalog-entities/extensions/' nor 'catalog-entities/marketplace/' directory", - flush=True) - - return default_plugins_file - -def main(): - - dynamic_plugins_root = sys.argv[1] - - lock_file_path = os.path.join(dynamic_plugins_root, 'install-dynamic-plugins.lock') - atexit.register(remove_lock, lock_file_path) - atexit.register(cleanup_catalog_index_temp_dir, dynamic_plugins_root) - 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: - # default to a temporary directory if the env var is not set - catalog_entities_parent_dir = os.environ.get("CATALOG_ENTITIES_EXTRACT_DIR", os.path.join(tempfile.gettempdir(), "extensions")) - catalog_index_default_file = extract_catalog_index(catalog_index_image, dynamic_plugins_root, catalog_entities_parent_dir) - - skip_integrity_check = os.environ.get("SKIP_INTEGRITY_CHECK", "").lower() == "true" - - dynamic_plugins_file = 'dynamic-plugins.yaml' - dynamic_plugins_global_config_file = os.path.join(dynamic_plugins_root, 'app-config.dynamic-plugins.yaml') - - # test if file dynamic-plugins.yaml exists - if not os.path.isfile(dynamic_plugins_file): - print(f"No {dynamic_plugins_file} file found. Skipping dynamic plugins installation.") - with open(dynamic_plugins_global_config_file, 'w') as file: - file.write('') - file.close() - exit(0) - - global_config = { - 'dynamicPlugins': { - 'rootDirectory': 'dynamic-plugins-root', - } - } - - with open(dynamic_plugins_file, 'r') as file: - content = yaml.safe_load(file) - - if content == '' or content is None: - print(f"{dynamic_plugins_file} file is empty. Skipping dynamic plugins installation.") - with open(dynamic_plugins_global_config_file, 'w') as file: - file.write('') - file.close() - exit(0) - - if not isinstance(content, dict): - raise InstallException(f"{dynamic_plugins_file} content must be a YAML object") - - all_plugins = {} - - if skip_integrity_check: - print(f"SKIP_INTEGRITY_CHECK has been set to {skip_integrity_check}, skipping integrity check of remote NPM 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 {dynamic_plugins_file}") - - # 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 {dynamic_plugins_file}") - - print('\n======= Including dynamic plugins from', include, flush=True) - - if not os.path.isfile(include): - print(f"WARNING: File {include} does not exist, skipping including dynamic packages from {include}", flush=True) - continue - - with open(include, 'r') as file: - include_content = yaml.safe_load(file) - - if not isinstance(include_content, dict): - raise InstallException(f"{include} content must be a YAML object") - - include_plugins = include_content['plugins'] - if not isinstance(include_plugins, list): - raise InstallException(f"content of the \'plugins\' field must be a list in {include}") - - for plugin in include_plugins: - merge_plugin(plugin, all_plugins, include, level=0) - - 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 {dynamic_plugins_file}") - - for plugin in plugins: - merge_plugin(plugin, all_plugins, dynamic_plugins_file, level=1) - - # add a hash for each plugin configuration to detect changes and check if version field is set for OCI packages - for plugin in all_plugins.values(): - hash_dict = copy.deepcopy(plugin) - # remove elements that shouldn't be tracked for installation detection - hash_dict.pop('pluginConfig', None) - # Don't track the internal version field used to track version inheritance - hash_dict.pop('version', None) - - package = plugin['package'] - if package.startswith('./'): - local_info = get_local_package_info(package) - hash_dict['_local_package_info'] = local_info - - plugin_hash = hashlib.sha256(json.dumps(hash_dict, sort_keys=True).encode('utf-8')).hexdigest() - plugin['plugin_hash'] = plugin_hash - - # create a dict of all currently installed plugins in dynamic_plugins_root - plugin_path_by_hash = {} - for dir_name in os.listdir(dynamic_plugins_root): - dir_path = os.path.join(dynamic_plugins_root, dir_name) - if os.path.isdir(dir_path): - hash_file_path = os.path.join(dir_path, 'dynamic-plugin-config.hash') - if os.path.isfile(hash_file_path): - with open(hash_file_path, 'r') as hash_file: - hash_value = hash_file.read().strip() - plugin_path_by_hash[hash_value] = dir_name - - # iterate through the list of plugins - for plugin in all_plugins.values(): - _, plugin_config = install_plugin(plugin, plugin_path_by_hash, dynamic_plugins_root, skip_integrity_check) - - # Merge plugin configuration if provided - if plugin_config: - global_config = maybe_merge_config(plugin_config, global_config) - - yaml.safe_dump(global_config, open(dynamic_plugins_global_config_file, 'w')) - - # remove plugins that have been removed from the configuration - for hash_value in plugin_path_by_hash: - plugin_directory = os.path.join(dynamic_plugins_root, plugin_path_by_hash[hash_value]) - print('\n======= Removing previously installed dynamic plugin', plugin_path_by_hash[hash_value], flush=True) - shutil.rmtree(plugin_directory, ignore_errors=True, onerror=None) - -if __name__ == '__main__': - main() diff --git a/scripts/install-dynamic-plugins/install-dynamic-plugins.sh b/scripts/install-dynamic-plugins/install-dynamic-plugins.sh index 4ddceb00fb..73beb13f04 100755 --- a/scripts/install-dynamic-plugins/install-dynamic-plugins.sh +++ b/scripts/install-dynamic-plugins/install-dynamic-plugins.sh @@ -1,5 +1,4 @@ -#!/bin/sh - +#!/usr/bin/env bash # # Copyright Red Hat, Inc. # Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,5 +13,15 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# Thin launcher for the TypeScript installer (see package.json / dist/install-dynamic-plugins.cjs). +# Build: cd here and run: yarn install && yarn build -python install-dynamic-plugins.py $1 +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "${SCRIPT_DIR}/dist/install-dynamic-plugins.cjs" ]]; then + # running locally + exec node "${SCRIPT_DIR}/dist/install-dynamic-plugins.cjs" "$@" +else + # in container + exec node "${SCRIPT_DIR}/install-dynamic-plugins.cjs" "$@" +fi diff --git a/scripts/install-dynamic-plugins/package.json b/scripts/install-dynamic-plugins/package.json new file mode 100644 index 0000000000..60c3164a1b --- /dev/null +++ b/scripts/install-dynamic-plugins/package.json @@ -0,0 +1,29 @@ +{ + "name": "@internal/install-dynamic-plugins", + "version": "1.0.0", + "private": true, + "description": "Dynamic Plugin Installer (TypeScript) — Registry HTTP API v2, npm pack, OCI images", + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json && rm -f dist/cli.cjs dist/cli.cjs.map && node esbuild.config.cjs", + "build:tsc": "tsc -p tsconfig.build.json", + "start": "node dist/install-dynamic-plugins.cjs", + "test": "vitest run", + "test:watch": "vitest", + "prepack": "yarn build" + }, + "dependencies": { + "proper-lockfile": "4.1.2", + "yaml": "2.8.2" + }, + "devDependencies": { + "@types/node": "22.19.3", + "esbuild": "0.27.2", + "typescript": "5.9.3", + "vitest": "3.2.4" + }, + "packageManager": "yarn@4.12.0" +} diff --git a/scripts/install-dynamic-plugins/pytest.ini b/scripts/install-dynamic-plugins/pytest.ini deleted file mode 100644 index 362e5c97a4..0000000000 --- a/scripts/install-dynamic-plugins/pytest.ini +++ /dev/null @@ -1,4 +0,0 @@ -[pytest] -markers = - integration: marks tests that require external dependencies (npm, skopeo, openssl, etc.) - diff --git a/scripts/install-dynamic-plugins/src/cli.ts b/scripts/install-dynamic-plugins/src/cli.ts new file mode 100644 index 0000000000..2e24c29ac2 --- /dev/null +++ b/scripts/install-dynamic-plugins/src/cli.ts @@ -0,0 +1,29 @@ +/** + * CLI entry — dynamic plugin installer (TypeScript). + */ +import { getOciPluginPaths } from './registry-oci.js'; +import { die, runMain } from './install.js'; + +async function main(): Promise { + const argv = process.argv.slice(2); + if (argv[0] === '--get-oci-paths') { + const image = argv[1] || ''; + if (!image) { + die('usage: --get-oci-paths '); + } + const paths = await getOciPluginPaths(image); + for (const p of paths) { + console.log(p); + } + return; + } + if (argv.length < 1) { + die(`usage: ${process.argv[1] || 'install-dynamic-plugins'} `); + } + await runMain(argv[0]!); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/install-dynamic-plugins/src/compute-plugin-hash.ts b/scripts/install-dynamic-plugins/src/compute-plugin-hash.ts new file mode 100644 index 0000000000..e5cfb60398 --- /dev/null +++ b/scripts/install-dynamic-plugins/src/compute-plugin-hash.ts @@ -0,0 +1,40 @@ +/** + * SHA256 of JSON matching Python json.dumps(obj, sort_keys=True, separators=(', ', ': ')) + */ +import { createHash } from 'node:crypto'; + +function pyStringify(obj: unknown): string { + if (obj === null) { + return 'null'; + } + const t = typeof obj; + if (t === 'boolean') { + return obj ? 'true' : 'false'; + } + if (t === 'number') { + if (!Number.isFinite(obj as number)) { + throw new Error('non-finite number'); + } + return JSON.stringify(obj); + } + if (t === 'string') { + return JSON.stringify(obj); + } + if (Array.isArray(obj)) { + const inner = obj.map(x => pyStringify(x)).join(', '); + return `[${inner}]`; + } + if (t === 'object') { + const keys = Object.keys(obj as object).sort(); + const parts = keys.map( + k => `${JSON.stringify(k)}: ${pyStringify((obj as Record)[k])}` + ); + return `{${parts.join(', ')}}`; + } + throw new Error(`unsupported type ${t}`); +} + +export function computePluginHashFromObject(obj: Record): string { + const s = pyStringify(obj); + return createHash('sha256').update(s, 'utf8').digest('hex'); +} diff --git a/scripts/install-dynamic-plugins/src/install.ts b/scripts/install-dynamic-plugins/src/install.ts new file mode 100644 index 0000000000..58daa737f0 --- /dev/null +++ b/scripts/install-dynamic-plugins/src/install.ts @@ -0,0 +1,522 @@ +/** + * Dynamic plugin installation orchestration (ported from install-dynamic-plugins.sh). + */ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + unlinkSync, + writeFileSync +} from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, join as pathJoin } from 'node:path'; +import lockfile from 'proper-lockfile'; +import { stringify as yamlStringify } from 'yaml'; +import { computePluginHashFromObject } from './compute-plugin-hash.js'; +import { mergeAppConfigFragments } from './merge-app-config.js'; +import { mergeDynamicPlugins } from './merge-dynamic-plugins.js'; +import { ociCopyImageLayer0, ociImageDigestHex } from './registry-oci.js'; + +const MAX_ENTRY_SIZE = Number(process.env.MAX_ENTRY_SIZE || '20000000'); +const SKIP_INTEGRITY_CHECK = process.env.SKIP_INTEGRITY_CHECK || ''; +const CATALOG_INDEX_IMAGE = process.env.CATALOG_INDEX_IMAGE || ''; +const CATALOG_ENTITIES_EXTRACT_DIR = process.env.CATALOG_ENTITIES_EXTRACT_DIR || ''; + +export function die(msg: string): never { + console.error(`install-dynamic-plugins: ${msg}`); + process.exit(1); +} + +let ociTmp = ''; +const ociTarCache = new Map(); + +function needCmd(name: string): void { + try { + execFileSync('sh', ['-c', 'command -v "$1"', 'sh', name], { + stdio: 'ignore' + }); + } catch { + die(`required command not found: ${name}`); + } +} + +function extractNpmTgz(archive: string, destDir: string): void { + const list = execFileSync('tar', ['-tf', archive], { encoding: 'utf8' }) + .split('\n') + .filter(Boolean); + const first = list[0]; + if (!first?.startsWith('package/')) { + die(`NPM package archive does not start with 'package/' as it should: ${first}`); + } + mkdirSync(destDir, { recursive: true }); + execFileSync('tar', ['-xzf', archive, '-C', destDir, '--strip-components=1']); +} + +function verifyIntegrity(pkgJson: Record, archive: string): void { + const integ = pkgJson.integrity as string | undefined; + if (!integ) { + die('Package integrity missing'); + } + const algo = integ.split('-')[0]!; + const b64 = integ.slice(integ.indexOf('-') + 1); + if (!['sha512', 'sha384', 'sha256'].includes(algo)) { + die(`unsupported integrity algorithm ${algo}`); + } + try { + Buffer.from(b64, 'base64'); + } catch { + die('integrity hash is not valid base64'); + } + const buf = readFileSync(archive); + const gotOpenssl = execFileSync('openssl', ['base64', '-A'], { + input: execFileSync('openssl', ['dgst', `-${algo}`, '-binary'], { input: buf }) + }) + .toString('utf8') + .replace(/\n/g, ''); + if (gotOpenssl !== b64) { + die(`integrity hash mismatch for ${String(pkgJson.package)}`); + } +} + +function getLocalPackageInfo(packagePath: string): Record { + const abs = pathJoin(process.cwd(), packagePath.replace(/^\.\//, '')); + if (!existsSync(`${abs}/package.json`)) { + if (existsSync(abs) && statSync(abs).isDirectory()) { + const mt = Math.floor(statSync(abs).mtimeMs / 1000); + return { _directory_mtime: mt }; + } + return { _not_found: true }; + } + let pj: Record; + try { + pj = JSON.parse(readFileSync(`${abs}/package.json`, 'utf8')) as Record; + } catch { + pj = {}; + } + const m = Math.floor(statSync(`${abs}/package.json`).mtimeMs / 1000); + let out: Record = { _package_json: pj, _package_json_mtime: m }; + if (existsSync(`${abs}/package-lock.json`)) { + const lm = Math.floor(statSync(`${abs}/package-lock.json`).mtimeMs / 1000); + out = { ...out, _package_lock_json_mtime: lm }; + } + if (existsSync(`${abs}/yarn.lock`)) { + const ym = Math.floor(statSync(`${abs}/yarn.lock`).mtimeMs / 1000); + out = { ...out, _yarn_lock_mtime: ym }; + } + return out; +} + +function computePluginHash(p: Record): string { + const pkg = String(p.package); + const base: Record = { ...p }; + delete base.pluginConfig; + delete base.version; + delete base.plugin_hash; + if (pkg.startsWith('./')) { + const info = getLocalPackageInfo(pkg); + return computePluginHashFromObject({ ...base, _local_package_info: info }); + } + return computePluginHashFromObject(base); +} + +function maybeMergeConfig( + frag: string, + globalJson: Record +): Record { + if (!frag || frag === '{}' || frag === 'null') { + return globalJson; + } + console.error('\t==> Merging plugin-specific configuration'); + const fragObj = JSON.parse(frag) as Record; + mergeAppConfigFragments(fragObj, globalJson); + return globalJson; +} + +async function extractCatalogIndex( + catalogImage: string, + mountRoot: string, + entitiesParent: string +): Promise { + console.error(`\n======= Extracting catalog index from ${catalogImage}`); + const tmp = await mkdtemp(pathJoin(tmpdir(), 'cat-idx-')); + try { + await ociCopyImageLayer0(catalogImage, pathJoin(tmp, 'oci')); + const manifestPath = pathJoin(tmp, 'oci', 'manifest.json'); + if (!existsSync(manifestPath)) { + die('manifest.json not found in catalog index image'); + } + const catalogTemp = pathJoin(mountRoot, '.catalog-index-temp'); + mkdirSync(catalogTemp, { recursive: true }); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + layers?: Array<{ digest?: string }>; + }; + console.error('\t==> Extracting catalog index layers'); + const layers = (manifest.layers || []) + .map(l => l.digest) + .filter(Boolean) as string[]; + for (const layer of layers) { + const fn = layer.includes(':') ? layer.split(':')[1]! : layer; + const layerPath = pathJoin(tmp, 'oci', fn); + if (!existsSync(layerPath)) { + continue; + } + console.error(`\t==> Extracting layer ${fn}`); + try { + const tv = execFileSync('tar', ['-tvf', layerPath], { + encoding: 'utf8', + maxBuffer: 50 * 1024 * 1024 + }); + for (const line of tv.split('\n')) { + const parts = line.trim().split(/\s+/); + if (parts.length < 2) { + continue; + } + const sz = parts[2]; + const pth = parts[parts.length - 1]; + if (!/^\d+$/.test(sz || '')) { + continue; + } + if (Number(sz) > MAX_ENTRY_SIZE) { + console.error(`\t==> WARNING: Skipping large file ${pth} in catalog index`); + } + } + } catch { + /* ignore */ + } + try { + execFileSync('tar', ['-xf', layerPath, '-C', catalogTemp]); + } catch { + /* ignore */ + } + } + const defaultYaml = pathJoin(catalogTemp, 'dynamic-plugins.default.yaml'); + if (!existsSync(defaultYaml)) { + die('Catalog index image does not contain dynamic-plugins.default.yaml'); + } + console.error( + '\t==> Successfully extracted dynamic-plugins.default.yaml from catalog index image' + ); + console.error(`\t==> Extracting extensions catalog entities to ${entitiesParent}`); + mkdirSync(entitiesParent, { recursive: true }); + const extdir = pathJoin(catalogTemp, 'catalog-entities', 'extensions'); + const mktdir = pathJoin(catalogTemp, 'catalog-entities', 'marketplace'); + let src = ''; + if (existsSync(extdir) && statSync(extdir).isDirectory()) { + src = extdir; + } else if (existsSync(mktdir) && statSync(mktdir).isDirectory()) { + src = mktdir; + } + if (src) { + const dest = pathJoin(entitiesParent, 'catalog-entities'); + rmSync(dest, { recursive: true, force: true }); + mkdirSync(dest, { recursive: true }); + execFileSync('cp', ['-a', `${src}/.`, `${dest}/`]); + console.error('\t==> Successfully extracted extensions catalog entities from index image'); + } else { + console.error( + `\t==> WARNING: Catalog index image does not have neither 'catalog-entities/extensions/' nor 'catalog-entities/marketplace/' directory` + ); + } + return defaultYaml; + } finally { + await rm(tmp, { recursive: true, force: true }).catch(() => undefined); + } +} + +function cleanupCatalog(root: string): void { + const p = pathJoin(root, '.catalog-index-temp'); + if (existsSync(p) && statSync(p).isDirectory()) { + rmSync(p, { recursive: true, force: true }); + console.error('\n======= Cleaning up temporary catalog index directory'); + } +} + +async function ociGetLayerTarball(image: string): Promise { + const key = createHash('sha256').update(image, 'utf8').digest('hex'); + const cached = ociTarCache.get(key); + if (cached) { + return cached; + } + const hdir = pathJoin(ociTmp, `oci-${key}`); + mkdirSync(hdir, { recursive: true }); + await ociCopyImageLayer0(image, hdir); + const mf = JSON.parse(readFileSync(pathJoin(hdir, 'manifest.json'), 'utf8')) as { + layers?: Array<{ digest?: string }>; + }; + const layer = mf.layers?.[0]?.digest; + if (!layer) { + die(`OCI image has no layers: ${image}`); + } + const hp = layer.includes(':') ? layer.split(':')[1]! : layer; + const tarPath = pathJoin(hdir, hp); + ociTarCache.set(key, tarPath); + return tarPath; +} + +async function shouldSkipOci( + pluginJson: Record, + dest: string, + pluginPathByHash: Map +): Promise<'install' | 'skip'> { + const ph = String(pluginJson.plugin_hash); + const pkg = String(pluginJson.package); + let policy: string; + if (Object.prototype.hasOwnProperty.call(pluginJson, 'pullPolicy')) { + policy = String(pluginJson.pullPolicy); + } else { + policy = pkg.includes(':latest!') ? 'Always' : 'IfNotPresent'; + } + if (!pluginPathByHash.has(ph)) { + return 'install'; + } + if (policy === 'IfNotPresent') { + return 'skip'; + } + if (!pkg.includes('!')) { + return 'install'; + } + const path_ = pkg.split('!').slice(1).join('!'); + const digestFile = pathJoin(dest, path_, 'dynamic-plugin-image.hash'); + const img = pkg.split('!')[0]!; + try { + const remote = await ociImageDigestHex(img); + if (existsSync(digestFile) && readFileSync(digestFile, 'utf8').trim() === remote) { + return 'skip'; + } + } catch { + return 'install'; + } + return 'install'; +} + +function shouldSkipNpm( + pluginJson: Record, + pluginPathByHash: Map +): 'install' | 'skip' { + const ph = String(pluginJson.plugin_hash); + if (!pluginPathByHash.has(ph)) { + return 'install'; + } + const policy = String(pluginJson.pullPolicy ?? 'IfNotPresent'); + const force = String(pluginJson.forceDownload ?? false); + if (force === 'true') { + return 'install'; + } + if (policy === 'Always') { + return 'install'; + } + return 'skip'; +} + +async function installOnePlugin( + dest: string, + pluginJson: Record, + skipInt: boolean, + pluginPathByHash: Map +): Promise { + const pkg = String(pluginJson.package); + const ph = String(pluginJson.plugin_hash); + + if (String(pluginJson.disabled) === 'true') { + console.error(`\n======= Skipping disabled dynamic plugin ${pkg}`); + return '{}'; + } + + let sk: 'install' | 'skip'; + if (pkg.startsWith('oci://')) { + sk = await shouldSkipOci(pluginJson, dest, pluginPathByHash); + } else { + sk = shouldSkipNpm(pluginJson, pluginPathByHash); + } + if (sk === 'skip') { + console.error(`\n======= Skipping download of already installed dynamic plugin ${pkg}`); + pluginPathByHash.delete(ph); + const pc = pluginJson.pluginConfig; + return JSON.stringify(pc && typeof pc === 'object' ? pc : {}); + } + + console.error(`\n======= Installing dynamic plugin ${pkg}`); + let pathOut: string; + + if (pkg.startsWith('oci://')) { + const bang = pkg.indexOf('!'); + if (bang < 0) { + die(`OCI package must resolve with !path: ${pkg}`); + } + const img = pkg.slice(0, bang); + const pluginPath = pkg.slice(bang + 1); + const tarb = await ociGetLayerTarball(img); + const pdir = pathJoin(dest, pluginPath); + if (existsSync(pdir)) { + rmSync(pdir, { recursive: true, force: true }); + } + mkdirSync(dest, { recursive: true }); + const members = execFileSync('tar', ['-tf', tarb], { encoding: 'utf8' }) + .split('\n') + .filter(line => line.startsWith(pluginPath)); + if (members.length > 0) { + execFileSync('tar', ['-xf', tarb, '-C', dest, ...members]); + } + const dg = await ociImageDigestHex(img); + mkdirSync(pdir, { recursive: true }); + writeFileSync(pathJoin(pdir, 'dynamic-plugin-image.hash'), dg, 'utf8'); + pathOut = pluginPath; + } else { + let packArg = pkg; + if (packArg.startsWith('./')) { + packArg = pathJoin(process.cwd(), packArg.replace(/^\.\//, '')); + } + if (!pkg.startsWith('./') && !skipInt && pluginJson.integrity === undefined) { + die(`No integrity hash provided for Package ${pkg}`); + } + console.error('\t==> Grabbing package archive through `npm pack`'); + const archiveName = execFileSync('npm', ['pack', packArg], { + cwd: dest, + encoding: 'utf8', + maxBuffer: 50 * 1024 * 1024 + }) + .trim() + .split(/\r?\n/) + .filter(Boolean) + .pop()!; + const archive = pathJoin(dest, archiveName); + if (!pkg.startsWith('./') && !skipInt) { + console.error('\t==> Verifying package integrity'); + verifyIntegrity(pluginJson, archive); + } + const baseName = basename(archive, '.tgz'); + const extractTo = pathJoin(dest, baseName); + if (existsSync(extractTo)) { + rmSync(extractTo, { recursive: true, force: true }); + } + mkdirSync(extractTo, { recursive: true }); + console.error(`\t==> Extracting package archive ${archive}`); + extractNpmTgz(archive, extractTo); + console.error(`\t==> Removing package archive ${archive}`); + unlinkSync(archive); + pathOut = baseName; + } + + writeFileSync(pathJoin(dest, pathOut, 'dynamic-plugin-config.hash'), ph, 'utf8'); + console.error(`\t==> Successfully installed dynamic plugin ${pkg}`); + for (const [k, v] of [...pluginPathByHash.entries()]) { + if (v === pathOut) { + pluginPathByHash.delete(k); + } + } + const pc = pluginJson.pluginConfig; + return JSON.stringify(pc && typeof pc === 'object' ? pc : {}); +} + +function checkYqNotNeeded(): void { + /* YAML handled by the `yaml` package; kept for parity with env docs. */ +} + +export async function runMain(dynamicPluginsRoot: string): Promise { + needCmd('openssl'); + needCmd('npm'); + needCmd(process.execPath); + checkYqNotNeeded(); + + ociTmp = await mkdtemp(pathJoin(tmpdir(), 'oci-inst-')); + mkdirSync(dynamicPluginsRoot, { recursive: true }); + const lockFile = pathJoin(dynamicPluginsRoot, '.install-dynamic-plugins.flock'); + const release = await lockfile.lock(lockFile, { realpath: false }); + try { + console.error(`======= Acquiring lock ${lockFile}`); + console.error(`======= Created lock file: ${lockFile}`); + + let catalogDefault = ''; + if (CATALOG_INDEX_IMAGE) { + let entParent = CATALOG_ENTITIES_EXTRACT_DIR; + if (!entParent) { + entParent = pathJoin(process.env.TMPDIR || '/tmp', 'extensions'); + } + catalogDefault = await extractCatalogIndex( + CATALOG_INDEX_IMAGE, + dynamicPluginsRoot, + entParent + ); + } + + const skipInt = + SKIP_INTEGRITY_CHECK.toLowerCase() === 'true' || SKIP_INTEGRITY_CHECK === '1'; + + const dynFile = 'dynamic-plugins.yaml'; + const globalOut = pathJoin(dynamicPluginsRoot, 'app-config.dynamic-plugins.yaml'); + + if (!existsSync(dynFile)) { + console.error(`No ${dynFile} file found. Skipping dynamic plugins installation.`); + writeFileSync(globalOut, '', 'utf8'); + return; + } + + let contentJson: Record; + try { + const raw = readFileSync(dynFile, 'utf8'); + const { parse } = await import('yaml'); + const parsed = parse(raw) as Record | null | undefined; + contentJson = parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + contentJson = {}; + } + if (!contentJson || Object.keys(contentJson).length === 0) { + console.error(`${dynFile} file is empty. Skipping dynamic plugins installation.`); + writeFileSync(globalOut, '', 'utf8'); + return; + } + + if (skipInt) { + console.error( + 'SKIP_INTEGRITY_CHECK has been set to true, skipping integrity check of remote NPM packages' + ); + } + + const merged = await mergeDynamicPlugins(dynFile, catalogDefault || ''); + + let globalJson: Record = { + dynamicPlugins: { rootDirectory: 'dynamic-plugins-root' } + }; + + const pluginPathByHash = new Map(); + for (const name of readdirSync(dynamicPluginsRoot)) { + const d = pathJoin(dynamicPluginsRoot, name); + if (!statSync(d).isDirectory()) { + continue; + } + const h = pathJoin(d, 'dynamic-plugin-config.hash'); + if (existsSync(h)) { + pluginPathByHash.set(readFileSync(h, 'utf8').trim(), name); + } + } + + for (const pjson of Object.values(merged)) { + const rec = pjson as Record; + const ph = computePluginHash(rec); + rec.plugin_hash = ph; + const cfg = await installOnePlugin(dynamicPluginsRoot, rec, skipInt, pluginPathByHash); + if (cfg !== '{}' && cfg) { + globalJson = maybeMergeConfig(cfg, globalJson); + } + } + + const yamlBody = yamlStringify(globalJson, { lineWidth: 120 }); + writeFileSync(globalOut, yamlBody, 'utf8'); + + for (const [, dirName] of pluginPathByHash.entries()) { + console.error(`\n======= Removing previously installed dynamic plugin ${dirName}`); + rmSync(pathJoin(dynamicPluginsRoot, dirName), { recursive: true, force: true }); + } + + cleanupCatalog(dynamicPluginsRoot); + } finally { + await release().catch(() => undefined); + await rm(ociTmp, { recursive: true, force: true }).catch(() => undefined); + } +} diff --git a/scripts/install-dynamic-plugins/src/merge-app-config.ts b/scripts/install-dynamic-plugins/src/merge-app-config.ts new file mode 100644 index 0000000000..c0ee98d1e9 --- /dev/null +++ b/scripts/install-dynamic-plugins/src/merge-app-config.ts @@ -0,0 +1,31 @@ +/** + * Merges pluginConfig fragment into global config (same rules as the installer's merge()). + */ +export function mergeAppConfigFragments( + source: Record, + destination: Record, + prefix = '' +): Record { + for (const key of Object.keys(source)) { + const value = source[key]; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + const node = + destination[key] !== undefined + ? (destination[key] as Record) + : ((destination[key] = {}) as Record); + mergeAppConfigFragments( + value as Record, + node, + `${prefix}${key}.` + ); + } else { + if (key in destination && destination[key] !== value) { + throw new Error( + `Config key '${prefix + key}' defined differently for 2 dynamic plugins` + ); + } + destination[key] = value; + } + } + return destination; +} diff --git a/scripts/install-dynamic-plugins/src/merge-dynamic-plugins.ts b/scripts/install-dynamic-plugins/src/merge-dynamic-plugins.ts new file mode 100644 index 0000000000..c80e9fe491 --- /dev/null +++ b/scripts/install-dynamic-plugins/src/merge-dynamic-plugins.ts @@ -0,0 +1,256 @@ +/** + * Merges NPM/OCI plugin entries (merge_plugin / PackageMerger behavior). + */ +import { existsSync, readFileSync } from 'node:fs'; +import { isAbsolute, join } from 'node:path'; +import { parse } from 'yaml'; +import { parseNpmPluginKey } from './npm-parse-plugin-key.js'; +import { parseOciPluginKey } from './oci-parse.js'; +import { parseOciRef } from './oci-ref.js'; +import { getOciPluginPaths } from './registry-oci.js'; + +export interface PluginRecord { + package: string; + last_modified_level?: number; + version?: string; + [key: string]: unknown; +} + +function yqToJson(filePath: string): Record { + const raw = readFileSync(filePath, 'utf8'); + const doc = parse(raw); + if (doc == null || typeof doc !== 'object') { + return {}; + } + return doc as Record; +} + +function ociParse( + package_: string, + file: string, + pathsJson: string[] | null +): ReturnType { + return parseOciPluginKey(package_, file, pathsJson); +} + +class NpmMerger { + constructor( + private readonly plugin: PluginRecord, + private readonly file: string, + private readonly allPlugins: Record + ) {} + + parsePluginKey(package_: string): string { + return parseNpmPluginKey(package_); + } + + mergePlugin(level: number): void { + const package_ = this.plugin.package; + if (typeof package_ !== 'string') { + throw new Error(`content of the 'package' field must be a string in ${this.file}`); + } + const pluginKey = this.parsePluginKey(package_); + if (!(pluginKey in this.allPlugins)) { + console.error(`\n======= Adding new dynamic plugin configuration for ${pluginKey}`); + this.plugin.last_modified_level = level; + this.allPlugins[pluginKey] = this.plugin; + } else { + console.error('\n======= Overriding dynamic plugin configuration', pluginKey); + if (this.allPlugins[pluginKey]!.last_modified_level === level) { + throw new Error( + `Duplicate plugin configuration for ${this.plugin.package} found in ${this.file}.` + ); + } + this.allPlugins[pluginKey]!.last_modified_level = level; + for (const key of Object.keys(this.plugin)) { + this.allPlugins[pluginKey]![key] = this.plugin[key]; + } + } + } +} + +class OciMerger { + constructor( + private readonly plugin: PluginRecord, + private readonly file: string, + private readonly allPlugins: Record + ) {} + + async mergePlugin(level: number): Promise { + const package_ = this.plugin.package; + if (typeof package_ !== 'string') { + throw new Error(`content of the 'package' field must be a string in ${this.file}`); + } + + let pathsFromManifest: string[] | null = null; + if (!package_.includes('!')) { + const ref = parseOciRef(package_); + pathsFromManifest = await getOciPluginPaths(ref.fullImage); + } + + let parsed: ReturnType; + try { + parsed = ociParse(package_, this.file, pathsFromManifest); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(msg.trim()); + } + + let pluginKey = parsed.plugin_key; + let version = parsed.version; + const inheritVersion = parsed.inherit_version; + const resolvedPath = parsed.resolved_path; + + if (inheritVersion && resolvedPath === null) { + const matches = Object.keys(this.allPlugins).filter(k => + k.startsWith(`${pluginKey}:!`) + ); + if (matches.length === 0) { + throw new Error( + `Cannot use {{inherit}} for ${pluginKey}: no existing plugin configuration found. ` + + `Ensure a plugin from this image is defined in an included file with an explicit version.` + ); + } + if (matches.length > 1) { + const fullPackages = matches.map(m => { + const base = this.allPlugins[m]!; + const baseVersion = (base.version as string) || ''; + const parts = m.split(':!'); + return `${parts[0]}:${baseVersion}!${parts[1]}`; + }); + throw new Error( + `Cannot use {{inherit}} for ${pluginKey}: multiple plugins from this image are defined in the included files:\n - ${fullPackages.join( + '\n - ' + )}\n` + + `Please specify which plugin configuration to inherit from using: ${pluginKey}:{{inherit}}!` + ); + } + pluginKey = matches[0]!; + const basePlugin = this.allPlugins[pluginKey]!; + const ver = basePlugin.version as string; + const rp = pluginKey.split(':!').pop()!; + const registryPart = pluginKey.split(':!')[0]!; + version = ver; + this.plugin.package = `${registryPart}:${version}!${rp}`; + console.error( + `\n======= Inheriting version \`${version}\` and plugin path \`${rp}\` for ${pluginKey}` + ); + } else if (!package_.includes('!')) { + this.plugin.package = `${package_}!${resolvedPath}`; + } + + if (!(pluginKey in this.allPlugins)) { + console.error( + `\n======= Adding new dynamic plugin configuration for version \`${version}\` of ${pluginKey}` + ); + this.plugin.last_modified_level = level; + if (inheritVersion === true) { + throw new Error( + `ERROR: {{inherit}} tag is set and there is currently no resolved tag or digest for ${this.plugin.package} in ${this.file}.` + ); + } + this.plugin.version = version; + this.allPlugins[pluginKey] = this.plugin; + } else { + console.error('\n======= Overriding dynamic plugin configuration', pluginKey); + if (this.allPlugins[pluginKey]!.last_modified_level === level) { + throw new Error( + `Duplicate plugin configuration for ${this.plugin.package} found in ${this.file}.` + ); + } + this.allPlugins[pluginKey]!.last_modified_level = level; + if (inheritVersion !== true) { + this.allPlugins[pluginKey]!.package = this.plugin.package; + if (this.allPlugins[pluginKey]!.version !== version) { + console.error( + `INFO: Overriding version for ${pluginKey} from \`${String(this.allPlugins[pluginKey]!.version)}\` to \`${version}\`` + ); + } + this.allPlugins[pluginKey]!.version = version; + } + for (const key of Object.keys(this.plugin)) { + if (key === 'package' || key === 'version') { + continue; + } + this.allPlugins[pluginKey]![key] = this.plugin[key]; + } + } + } +} + +async function mergePlugin( + plugin: PluginRecord, + allPlugins: Record, + dynamicPluginsFile: string, + level: number +): Promise { + const package_ = plugin.package; + if (typeof package_ !== 'string') { + throw new Error( + `content of the 'plugins.package' field must be a string in ${dynamicPluginsFile}` + ); + } + if (package_.startsWith('oci://')) { + await new OciMerger(plugin, dynamicPluginsFile, allPlugins).mergePlugin(level); + } else { + new NpmMerger(plugin, dynamicPluginsFile, allPlugins).mergePlugin(level); + } +} + +export async function mergeDynamicPlugins( + dynamicPluginsFile: string, + catalogDefault = '' +): Promise> { + const content = yqToJson(dynamicPluginsFile); + if (!content || typeof content !== 'object') { + return {}; + } + let includes = content.includes as string[] | undefined; + if (!Array.isArray(includes)) { + includes = []; + } + if (catalogDefault && includes.includes('dynamic-plugins.default.yaml')) { + const idx = includes.indexOf('dynamic-plugins.default.yaml'); + includes = [...includes]; + includes[idx] = catalogDefault; + } + + const allPlugins: Record = {}; + + for (const inc of includes) { + if (typeof inc !== 'string') { + throw new Error( + `content of the 'includes' field must be a list of strings in ${dynamicPluginsFile}` + ); + } + console.error('\n======= Including dynamic plugins from', inc); + const p = isAbsolute(inc) ? inc : join(process.cwd(), inc); + if (!existsSync(p)) { + console.error( + `WARNING: File ${inc} does not exist, skipping including dynamic packages from ${inc}` + ); + continue; + } + const incContent = yqToJson(p); + if (typeof incContent !== 'object' || incContent === null) { + throw new Error(`${inc} content must be a YAML object`); + } + const plist = incContent.plugins as unknown; + if (!Array.isArray(plist)) { + throw new Error(`content of the 'plugins' field must be a list in ${inc}`); + } + for (const plugin of plist as PluginRecord[]) { + await mergePlugin(plugin, allPlugins, p, 0); + } + } + + let plugins = content.plugins as unknown; + if (!Array.isArray(plugins)) { + plugins = []; + } + for (const plugin of plugins as PluginRecord[]) { + await mergePlugin(plugin, allPlugins, dynamicPluginsFile, 1); + } + + return allPlugins; +} diff --git a/scripts/install-dynamic-plugins/src/npm-parse-plugin-key.ts b/scripts/install-dynamic-plugins/src/npm-parse-plugin-key.ts new file mode 100644 index 0000000000..d47b4a04a5 --- /dev/null +++ b/scripts/install-dynamic-plugins/src/npm-parse-plugin-key.ts @@ -0,0 +1,53 @@ +/** + * Parses NPM plugin keys (strip version, local paths, etc.). + */ +const NPM_ALIAS_PATTERN = /^([^@]+)@npm:((?:@[^/]+\/)?)([^@]+)(?:@(.+))?$/; + +const GIT_URL_PATTERNS = [ + /^git\+https?:\/\/[^#]+(?:#(.+))?$/, + /^git\+ssh:\/\/[^#]+(?:#(.+))?$/, + /^git:\/\/[^#]+(?:#(.+))?$/, + /^https:\/\/github\.com\/[^/]+\/[^/#]+(?:\.git)?(?:#(.+))?$/, + /^git@github\.com:[^/]+\/[^/#]+(?:\.git)?(?:#(.+))?$/, + /^github:([^/@]+)\/([^/#]+)(?:#(.+))?$/, + /^([^/@]+)\/([^/#]+)(?:#(.+))?$/ +]; + +function stripNpmPackageVersion(package_: string): string { + const m = package_.match(/^(@[^/]+\/)?([^@]+)(?:@(.+))?$/); + if (m) { + const scope = m[1] || ''; + const pkgName = m[2]!; + return `${scope}${pkgName}`; + } + return package_; +} + +export function parseNpmPluginKey(package_: string): string { + if (typeof package_ !== 'string') { + return ''; + } + if (package_.startsWith('./')) { + return package_; + } + if (package_.endsWith('.tgz')) { + return package_; + } + + const aliasMatch = package_.match(NPM_ALIAS_PATTERN); + if (aliasMatch) { + const aliasName = aliasMatch[1]!; + const packageScope = aliasMatch[2] || ''; + const npmPackage = aliasMatch[3]!; + const npmKey = stripNpmPackageVersion(packageScope + npmPackage); + return `${aliasName}@npm:${npmKey}`; + } + + for (const pat of GIT_URL_PATTERNS) { + if (pat.test(package_)) { + return package_.split('#')[0]!; + } + } + + return stripNpmPackageVersion(package_); +} diff --git a/scripts/install-dynamic-plugins/src/oci-parse.ts b/scripts/install-dynamic-plugins/src/oci-parse.ts new file mode 100644 index 0000000000..8d89792b8b --- /dev/null +++ b/scripts/install-dynamic-plugins/src/oci-parse.ts @@ -0,0 +1,89 @@ +/** + * OCI package parsing (image ref, digest) for plugin entries. + */ +const OCI_PROTOCOL_PREFIX = 'oci://'; +const EXPECTED_OCI_PATTERN = new RegExp( + '^(' + + OCI_PROTOCOL_PREFIX + + '[^\\s/:@]+' + + '(?::\\d+)?' + + '(?:/[^\\s:@]+)+' + + ')' + + '(?:' + + ':([^\\s!@:]+)' + + '|' + + '@((?:sha256|sha512|blake3):[^\\s!@:]+)' + + ')' + + '(?:!([^\\s]+))?$' +); + +export interface OciParseResult { + plugin_key: string; + version: string; + inherit_version: boolean; + resolved_path: string | null; + full_image: string | null; +} + +export function parseOciPluginKey( + package_: string, + dynamicPluginsFile: string, + pathsFromManifest: string[] | null +): OciParseResult { + const m = package_.match(EXPECTED_OCI_PATTERN); + if (!m) { + throw new Error( + `oci package '${package_}' is not in the expected format in ${dynamicPluginsFile}` + ); + } + const registry = m[1]!; + const tagVersion = m[2]; + const digestVersion = m[3]; + const version = tagVersion || digestVersion!; + let path = m[4]; + + const inheritVersion = tagVersion === '{{inherit}}' && digestVersion == null; + + if (inheritVersion && !path) { + return { + plugin_key: registry, + version, + inherit_version: true, + resolved_path: null, + full_image: null + }; + } + + if (!path) { + const fullImage = tagVersion + ? `${registry}:${version}` + : `${registry}@${version}`; + if (!pathsFromManifest || pathsFromManifest.length === 0) { + throw new Error(`No plugins found in OCI image ${fullImage}.`); + } + if (pathsFromManifest.length > 1) { + const pluginsList = pathsFromManifest.join('\n - '); + throw new Error( + `Multiple plugins found in OCI image ${fullImage}:\n - ${pluginsList}\n` + + `Please specify which plugin to install using the syntax: ${fullImage}!` + ); + } + path = pathsFromManifest[0]!; + return { + plugin_key: `${registry}:!${path}`, + version, + inherit_version: false, + resolved_path: path, + full_image: fullImage + }; + } + + const pluginKey = `${registry}:!${path}`; + return { + plugin_key: pluginKey, + version, + inherit_version: inheritVersion, + resolved_path: path, + full_image: null + }; +} diff --git a/scripts/install-dynamic-plugins/src/oci-ref.ts b/scripts/install-dynamic-plugins/src/oci-ref.ts new file mode 100644 index 0000000000..979e4589a2 --- /dev/null +++ b/scripts/install-dynamic-plugins/src/oci-ref.ts @@ -0,0 +1,59 @@ +/** + * Parse oci:// references for Registry HTTP API + */ +const OCI_PREFIX = 'oci://'; +const RE = new RegExp( + '^(' + + OCI_PREFIX + + '[^\\s/:@]+' + + '(?::\\d+)?' + + '(?:/[^\\s:@]+)+' + + ')' + + '(?:' + + ':([^\\s!@:]+)' + + '|' + + '@((?:sha256|sha512|blake3):[^\\s!@:]+)' + + ')' + + '(?:!([^\\s]+))?$' +); + +export interface OciParsedRef { + registry: string; + repository: string; + reference: string; + kind: 'tag' | 'digest'; + pluginPath: string | null; + fullImage: string; + registryWithPrefix: string; +} + +export function parseOciRef(ref: string): OciParsedRef { + const m = ref.match(RE); + if (!m) { + throw new Error(`invalid OCI reference: ${ref}`); + } + const registryWithPrefix = m[1]!; + const tag = m[2]; + const digest = m[3]; + const version = tag || digest; + const path = m[4]; + const withoutProto = registryWithPrefix.replace(/^oci:\/\//, ''); + const slash = withoutProto.indexOf('/'); + if (slash < 0) { + throw new Error(`invalid OCI reference (no repository path): ${ref}`); + } + const registry = withoutProto.slice(0, slash); + const repository = withoutProto.slice(slash + 1); + const fullImage = tag + ? `${registryWithPrefix}:${version}` + : `${registryWithPrefix}@${version}`; + return { + registry, + repository, + reference: version!, + kind: tag ? 'tag' : 'digest', + pluginPath: path || null, + fullImage, + registryWithPrefix + }; +} diff --git a/scripts/install-dynamic-plugins/src/registry-oci.ts b/scripts/install-dynamic-plugins/src/registry-oci.ts new file mode 100644 index 0000000000..1b762fb79e --- /dev/null +++ b/scripts/install-dynamic-plugins/src/registry-oci.ts @@ -0,0 +1,325 @@ +/** + * OCI registry HTTP API v2 (fetch-based; mirrors bash + curl behavior). + */ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join as pathJoin } from 'node:path'; +import { parseOciRef } from './oci-ref.js'; + +const DOCKER_PROTOCOL_PREFIX = 'docker://'; +export const RHDH_REGISTRY_PREFIX = 'registry.access.redhat.com/rhdh/'; +const RHDH_FALLBACK_PREFIX = 'quay.io/rhdh/'; + +const ACCEPT_HEADERS = [ + 'application/vnd.docker.distribution.manifest.v2+json', + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.oci.image.manifest.v1+json', + 'application/vnd.oci.image.index.v1+json' +]; + +function buildAcceptHeader(): string { + return ACCEPT_HEADERS.join(', '); +} + +export interface RegistryNormalize { + registry: string; + repository: string; +} + +export function registryNormalize(reg: string, repo: string): RegistryNormalize { + let r = reg; + let p = repo; + if (r === 'docker.io') { + r = 'registry-1.docker.io'; + if (!p.includes('/')) { + p = `library/${p}`; + } + } + return { registry: r, repository: p }; +} + +function parseAuthLine(line: string): { realm: string; service: string; scope: string } { + let realm = ''; + let service = ''; + let scope = ''; + const rm = line.match(/realm="([^"]+)"/); + const sm = line.match(/service="([^"]+)"/); + const scm = line.match(/scope="([^"]+)"/); + if (rm) { + realm = rm[1]!; + } + if (sm) { + service = sm[1]!; + } + if (scm) { + scope = scm[1]!; + } + return { realm, service, scope }; +} + +export function registryUrl( + reg: string, + repo: string, + kind: 'manifests' | 'blobs', + ref: string +): string { + const { registry, repository } = registryNormalize(reg, repo); + const rpath = encodeURIComponent(repository).replace(/%2F/g, '/'); + const base = `https://${registry}`; + if (kind === 'manifests') { + return `${base}/v2/${rpath}/manifests/${ref}`; + } + return `${base}/v2/${rpath}/blobs/${ref}`; +} + +async function registryGetToken( + realm: string, + service: string, + scope: string +): Promise { + const sep = realm.includes('?') ? '&' : '?'; + const u = `${realm}${sep}service=${encodeURIComponent(service)}&scope=${encodeURIComponent(scope)}`; + const res = await fetch(u); + if (!res.ok) { + return ''; + } + const j = (await res.json()) as { token?: string; access_token?: string }; + return j.token || j.access_token || ''; +} + +async function registryGet( + url: string, + outPath: string | null, + tok?: string +): Promise<{ status: number; digest: string }> { + const doFetch = async (authorization?: string) => { + const headers = new Headers(); + headers.set('Accept', buildAcceptHeader()); + if (authorization) { + headers.set('Authorization', authorization); + } + return fetch(url, { headers, redirect: 'follow' }); + }; + + let res = await doFetch(tok ? `Bearer ${tok}` : undefined); + let digest = (res.headers.get('docker-content-digest') || '').trim(); + + if (res.status === 401 && !tok) { + await res.arrayBuffer().catch(() => undefined); + const www = res.headers.get('www-authenticate'); + if (!www) { + return { status: 401, digest: '' }; + } + const line = www.trim(); + const { realm, service, scope } = parseAuthLine(line); + if (!realm) { + return { status: 401, digest: '' }; + } + const newTok = await registryGetToken(realm, service, scope); + if (!newTok) { + return { status: 401, digest: '' }; + } + res = await doFetch(`Bearer ${newTok}`); + digest = (res.headers.get('docker-content-digest') || '').trim(); + } + + if (outPath) { + const buf = Buffer.from(await res.arrayBuffer()); + if (outPath !== '/dev/null') { + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, buf); + } + } else { + await res.arrayBuffer().catch(() => undefined); + } + + return { status: res.status, digest }; +} + +export async function ociFetchManifestResolved( + reg: string, + repo: string, + ref: string +): Promise<{ manifest: string; digest: string }> { + const url = registryUrl(reg, repo, 'manifests', ref); + const tmpd = await mkdtemp(pathJoin(tmpdir(), 'oci-mf-')); + const bodyPath = pathJoin(tmpd, 'b'); + try { + const { status, digest: d0 } = await registryGet(url, bodyPath); + if (status !== 200) { + throw new Error(`registry GET ${url} failed with HTTP ${status}`); + } + const body = await readFile(bodyPath, 'utf8'); + let manifestDigest = d0; + const med = JSON.parse(body).mediaType as string | undefined; + + if (med && (med.includes('manifest.list') || med.includes('image.index'))) { + const idx = JSON.parse(body) as { + manifests?: Array<{ + platform?: { os?: string; architecture?: string }; + digest?: string; + }>; + }; + const dg = idx.manifests?.find( + m => m.platform?.os === 'linux' && m.platform?.architecture === 'amd64' + )?.digest; + if (!dg) { + throw new Error(`no linux/amd64 entry in manifest index for ${reg}/${repo}:${ref}`); + } + const url2 = registryUrl(reg, repo, 'manifests', dg); + const tmpd2 = await mkdtemp(pathJoin(tmpdir(), 'oci-mf2-')); + const bodyPath2 = pathJoin(tmpd2, 'b2'); + try { + const { status: st2, digest: d2 } = await registryGet(url2, bodyPath2); + if (st2 !== 200) { + throw new Error(`registry GET manifest ${dg} failed HTTP ${st2}`); + } + const inner = await readFile(bodyPath2, 'utf8'); + manifestDigest = d2 || manifestDigest; + return { manifest: inner, digest: manifestDigest }; + } finally { + await rm(tmpd2, { recursive: true, force: true }).catch(() => undefined); + } + } + + return { manifest: body, digest: manifestDigest }; + } finally { + await rm(tmpd, { recursive: true, force: true }).catch(() => undefined); + } +} + +export async function ociFetchBlobToFile( + reg: string, + repo: string, + digest: string, + dest: string +): Promise { + const url = registryUrl(reg, repo, 'blobs', digest); + const { status } = await registryGet(url, dest); + if (status !== 200) { + throw new Error(`blob download failed HTTP ${status} for ${digest}`); + } +} + +export async function resolveImageReferenceAsync(image: string): Promise { + let check = image; + let prefix = ''; + if (check.startsWith('oci://')) { + check = check.slice('oci://'.length); + prefix = 'oci://'; + } else if (check.startsWith('docker://')) { + check = check.slice('docker://'.length); + prefix = 'docker://'; + } + if (!check.startsWith(RHDH_REGISTRY_PREFIX)) { + return image; + } + console.error(`\t==> Checking if image exists in ${RHDH_REGISTRY_PREFIX}`); + const dockerUrl = `${DOCKER_PROTOCOL_PREFIX}${check}`; + const exists = await imageExistsInRegistry(dockerUrl); + if (exists) { + console.error(`\t==> Image found in ${RHDH_REGISTRY_PREFIX}`); + return image; + } + const fb = check.replace(RHDH_REGISTRY_PREFIX, RHDH_FALLBACK_PREFIX); + console.error( + `\t==> Image not found in ${RHDH_REGISTRY_PREFIX}, falling back to ${RHDH_FALLBACK_PREFIX}` + ); + console.error(`\t==> Using fallback image: ${fb}`); + return `${prefix}${fb}`; +} + +async function imageExistsInRegistry(dockerUrl: string): Promise { + const img = dockerUrl.startsWith('docker://') + ? dockerUrl.slice('docker://'.length) + : dockerUrl; + let reg: string; + let repo: string; + let ref: string; + if (img.includes('@')) { + reg = img.split('/')[0]!; + const rest = img.slice(img.indexOf('/') + 1); + repo = rest.split('@')[0]!; + ref = rest.split('@')[1]!; + } else { + reg = img.split('/')[0]!; + const rest = img.slice(img.indexOf('/') + 1); + const ci = rest.indexOf(':'); + repo = rest.slice(0, ci); + ref = rest.slice(ci + 1); + } + const url = registryUrl(reg, repo, 'manifests', ref); + const tmpd = await mkdtemp(pathJoin(tmpdir(), 'oci-ie-')); + const nullPath = pathJoin(tmpd, 'n'); + try { + const { status } = await registryGet(url, nullPath); + return status === 200; + } finally { + await rm(tmpd, { recursive: true, force: true }).catch(() => undefined); + } +} + +export async function getOciPluginPaths(image: string): Promise { + const resolved = await resolveImageReferenceAsync(image); + const mj = parseOciRef(resolved); + const { manifest } = await ociFetchManifestResolved( + mj.registry, + mj.repository, + mj.reference + ); + const ann = JSON.parse(manifest).annotations?.[ + 'io.backstage.dynamic-packages' + ] as string | undefined; + if (!ann) { + return []; + } + let dec: string; + try { + dec = Buffer.from(ann, 'base64').toString('utf8'); + } catch { + return []; + } + try { + const parsed = JSON.parse(dec) as unknown[]; + const keys: string[] = []; + for (const item of parsed) { + if (item && typeof item === 'object') { + keys.push(...Object.keys(item as object)); + } + } + return keys; + } catch { + return []; + } +} + +export async function ociImageDigestHex(image: string): Promise { + const resolved = await resolveImageReferenceAsync(image); + const mj = parseOciRef(resolved); + const { digest } = await ociFetchManifestResolved(mj.registry, mj.repository, mj.reference); + const d = digest || ''; + if (!d) { + throw new Error(`could not read manifest digest for ${image}`); + } + return d.startsWith('sha256:') ? d.slice('sha256:'.length) : d; +} + +export async function ociCopyImageLayer0(image: string, outDir: string): Promise { + const resolved = await resolveImageReferenceAsync(image); + console.error(`\t==> Copying image ${resolved} to local filesystem (registry API)`); + const mj = parseOciRef(resolved); + const { manifest } = await ociFetchManifestResolved( + mj.registry, + mj.repository, + mj.reference + ); + await mkdir(outDir, { recursive: true }); + await writeFile(pathJoin(outDir, 'manifest.json'), manifest); + const man = JSON.parse(manifest) as { layers?: Array<{ digest?: string }> }; + const layer = man.layers?.[0]?.digest; + if (!layer) { + throw new Error(`OCI image has no layers: ${image}`); + } + const hashpart = layer.startsWith('sha256:') ? layer.slice('sha256:'.length) : layer; + await ociFetchBlobToFile(mj.registry, mj.repository, layer, pathJoin(outDir, hashpart)); +} diff --git a/scripts/install-dynamic-plugins/src/types/proper-lockfile.d.ts b/scripts/install-dynamic-plugins/src/types/proper-lockfile.d.ts new file mode 100644 index 0000000000..7fb61ce864 --- /dev/null +++ b/scripts/install-dynamic-plugins/src/types/proper-lockfile.d.ts @@ -0,0 +1,7 @@ +declare module 'proper-lockfile' { + export function lock( + path: string, + options?: { stale?: number; retries?: number; realpath?: boolean } + ): Promise<() => Promise> + export function unlock(path: string): Promise +} diff --git a/scripts/install-dynamic-plugins/test/install-dynamic-plugins.test.ts b/scripts/install-dynamic-plugins/test/install-dynamic-plugins.test.ts new file mode 100644 index 0000000000..79371c5243 --- /dev/null +++ b/scripts/install-dynamic-plugins/test/install-dynamic-plugins.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for @internal/install-dynamic-plugins (replaces former shell suite). + */ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { parseNpmPluginKey } from '../src/npm-parse-plugin-key.js'; +import { parseOciRef } from '../src/oci-ref.js'; +import { runMain } from '../src/install.js'; + +const EXPECTED_SEMVER_CONFIG_HASH = + '9a1c28348ec09ef4d6d989ee83ac5bbf08e5ba16709fcc55516ca040186377f8'; + +describe('parseNpmPluginKey', () => { + test('strips version from scoped backstage package', () => { + expect(parseNpmPluginKey('@backstage/plugin-catalog@1.0.0')).toBe( + '@backstage/plugin-catalog' + ); + }); + + test('preserves local path', () => { + expect(parseNpmPluginKey('./local')).toBe('./local'); + }); +}); + +describe('parseOciRef', () => { + test('parses oci://host/path:tag', () => { + const r = parseOciRef('oci://quay.io/user/plugin:v1.0'); + expect(r.registry).toBe('quay.io'); + expect(r.repository).toBe('user/plugin'); + }); +}); + +describe('runMain integration', () => { + let prevCwd: string; + const tmpDirs: string[] = []; + + beforeEach(() => { + prevCwd = process.cwd(); + }); + + afterEach(() => { + process.chdir(prevCwd); + for (const d of tmpDirs.splice(0)) { + try { + rmSync(d, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } + }); + + function workdir(): string { + const w = mkdtempSync(join(tmpdir(), 'idp-test-')); + tmpDirs.push(w); + return w; + } + + test('empty plugins list produces app-config.dynamic-plugins.yaml', async () => { + const w = workdir(); + mkdirSync(join(w, 'out'), { recursive: true }); + writeFileSync(join(w, 'dynamic-plugins.yaml'), 'plugins: []\n'); + process.chdir(w); + await runMain(join(w, 'out')); + expect(existsSync(join(w, 'out', 'app-config.dynamic-plugins.yaml'))).toBe( + true + ); + }); + + test('semver@7.0.0 install writes expected plugin hash and package.json', async () => { + const w = workdir(); + mkdirSync(join(w, 'out'), { recursive: true }); + writeFileSync( + join(w, 'dynamic-plugins.yaml'), + `plugins: + - package: semver@7.0.0 + integrity: sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== +` + ); + process.chdir(w); + await runMain(join(w, 'out')); + const h = readFileSync( + join(w, 'out', 'semver-7.0.0', 'dynamic-plugin-config.hash'), + 'utf8' + ).trim(); + expect(h).toBe(EXPECTED_SEMVER_CONFIG_HASH); + expect(existsSync(join(w, 'out', 'semver-7.0.0', 'package.json'))).toBe( + true + ); + }); +}); diff --git a/scripts/install-dynamic-plugins/test_install-dynamic-plugins.py b/scripts/install-dynamic-plugins/test_install-dynamic-plugins.py deleted file mode 100644 index 196942433b..0000000000 --- a/scripts/install-dynamic-plugins/test_install-dynamic-plugins.py +++ /dev/null @@ -1,3065 +0,0 @@ -# -# 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. -# -""" -Unit tests for install-dynamic-plugins.py - -This test suite covers: -- NPMPackageMerger.parse_plugin_key() - Version stripping from NPM packages -- 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: - $ pip install -r ../python/requirements-dev.txt - -Running tests: - Run all tests: - $ pytest test_install-dynamic-plugins.py -v - - Run specific test class: - $ pytest test_install-dynamic-plugins.py::TestNPMPackageMergerParsePluginKey -v - - Run with coverage: - $ pytest test_install-dynamic-plugins.py --cov -v -""" - -import pytest -import sys -import os -import importlib.util -import json -import hashlib -import base64 - -# Add the current directory to path to import the module -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -# Import from file with hyphens in name using importlib -script_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'install-dynamic-plugins.py') -spec = importlib.util.spec_from_file_location("install_dynamic_plugins", script_path) -install_dynamic_plugins = importlib.util.module_from_spec(spec) -spec.loader.exec_module(install_dynamic_plugins) - -# Import the classes and exception from the loaded module -NPMPackageMerger = install_dynamic_plugins.NPMPackageMerger -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.""" - - @pytest.fixture - def npm_merger(self): - """Create an NPMPackageMerger instance for testing.""" - plugin = {'package': 'test-package'} - return NPMPackageMerger(plugin, 'test-file.yaml', {}) - - @pytest.mark.parametrize("input_package,expected_output", [ - # Standard NPM packages with version stripping - ('@npmcli/arborist@latest', '@npmcli/arborist'), - ('@backstage/plugin-catalog@1.0.0', '@backstage/plugin-catalog'), - ('semver@7.2.2', 'semver'), - ('package-name@^1.0.0', 'package-name'), - ('package-name@~2.1.0', 'package-name'), - ('package-name@1.x', 'package-name'), - - # Packages without version (unchanged) - ('package-name', 'package-name'), - ('@scope/package', '@scope/package'), - - # NPM aliases with version stripping - ('semver:@npm:semver@7.2.2', 'semver:@npm:semver'), - ('my-alias@npm:@npmcli/semver-with-patch', 'my-alias@npm:@npmcli/semver-with-patch'), - ('semver:@npm:@npmcli/semver-with-patch@1.0.0', 'semver:@npm:@npmcli/semver-with-patch'), - ('alias@npm:package@1.0.0', 'alias@npm:package'), - ('alias@npm:@scope/package@2.0.0', 'alias@npm:@scope/package'), - - # Git URLs with ref stripping - ('npm/cli#c12ea07', 'npm/cli'), - ('user/repo#main', 'user/repo'), - ('github:user/repo#ref', 'github:user/repo'), - ('git+https://github.com/user/repo.git#branch', 'git+https://github.com/user/repo.git'), - ('git+https://github.com/user/repo#branch', 'git+https://github.com/user/repo'), - ('git@github.com:user/repo.git#ref', 'git@github.com:user/repo.git'), - ('git+ssh://git@github.com/user/repo.git#tag', 'git+ssh://git@github.com/user/repo.git'), - ('git://github.com/user/repo#commit', 'git://github.com/user/repo'), - ('https://github.com/user/repo.git#v1.0.0', 'https://github.com/user/repo.git'), - - # Local paths (unchanged) - ('./my-local-plugin', './my-local-plugin'), - ('./path/to/plugin', './path/to/plugin'), - - # Tarballs (unchanged) - ('package.tgz', 'package.tgz'), - ('my-package-1.0.0.tgz', 'my-package-1.0.0.tgz'), - ('https://example.com/package.tgz', 'https://example.com/package.tgz'), - ]) - def test_parse_plugin_key_success_cases(self, npm_merger, input_package, expected_output): - """Test that parse_plugin_key correctly strips versions and refs from various package formats.""" - result = npm_merger.parse_plugin_key(input_package) - assert result == expected_output, f"Expected {expected_output}, got {result}" - - -class TestOciPackageMergerParsePluginKey: - """Test cases for OciPackageMerger.parse_plugin_key() method.""" - - @pytest.fixture - def oci_merger(self): - """Create an OciPackageMerger instance for testing.""" - plugin = {'package': 'oci://example.com:v1.0!plugin'} - return OciPackageMerger(plugin, 'test-file.yaml', {}) - - @pytest.mark.parametrize("input_package,expected_key,expected_version,expected_inherit", [ - # Tag-based packages with explicit path - ( - 'oci://quay.io/user/plugin:v1.0!plugin-name', - 'oci://quay.io/user/plugin:!plugin-name', - 'v1.0', - False - ), - ( - 'oci://registry.io/plugin:latest!path/to/plugin', - 'oci://registry.io/plugin:!path/to/plugin', - 'latest', - False - ), - ( - 'oci://ghcr.io/org/plugin:1.2.3!my-plugin', - 'oci://ghcr.io/org/plugin:!my-plugin', - '1.2.3', - False - ), - ( - 'oci://docker.io/library/plugin:v2.0.0!plugin', - 'oci://docker.io/library/plugin:!plugin', - 'v2.0.0', - False - ), - - # Digest-based packages with different algorithms - ( - 'oci://quay.io/user/plugin@sha256:abc123def456!plugin', - 'oci://quay.io/user/plugin:!plugin', - 'sha256:abc123def456', - False - ), - ( - 'oci://registry.io/plugin@sha512:fedcba987654!plugin', - 'oci://registry.io/plugin:!plugin', - 'sha512:fedcba987654', - False - ), - ( - 'oci://example.com/plugin@blake3:1234567890abcdef!my-plugin', - 'oci://example.com/plugin:!my-plugin', - 'blake3:1234567890abcdef', - False - ), - - # Inherit version pattern - ( - 'oci://quay.io/user/plugin:{{inherit}}!plugin', - 'oci://quay.io/user/plugin:!plugin', - '{{inherit}}', - True - ), - ( - 'oci://registry.io/plugin:{{inherit}}!path/to/plugin', - 'oci://registry.io/plugin:!path/to/plugin', - '{{inherit}}', - True - ), - - # Host:port registry format - ( - 'oci://registry.localhost:5000/rhdh-plugins/plugin:v1.0!plugin-name', - 'oci://registry.localhost:5000/rhdh-plugins/plugin:!plugin-name', - 'v1.0', - False - ), - ( - 'oci://registry.localhost:5000/rhdh-plugins/rhdh-plugin-export-overlays/backstage-community-plugin-scaffolder-backend-module-quay:bs_1.45.3__2.14.0!my-plugin', - 'oci://registry.localhost:5000/rhdh-plugins/rhdh-plugin-export-overlays/backstage-community-plugin-scaffolder-backend-module-quay:!my-plugin', - 'bs_1.45.3__2.14.0', - False - ), - ( - 'oci://registry.localhost:5000/path@sha256:abc123!plugin', - 'oci://registry.localhost:5000/path:!plugin', - 'sha256:abc123', - False - ), - ( - 'oci://registry.localhost:5000/path:{{inherit}}!plugin', - 'oci://registry.localhost:5000/path:!plugin', - '{{inherit}}', - True - ), - ( - 'oci://10.0.0.1:5000/repo/plugin:tag!plugin', # NOSONAR - 'oci://10.0.0.1:5000/repo/plugin:!plugin', # NOSONAR - 'tag', - False - ), - ]) - def test_parse_plugin_key_success_cases( - self, oci_merger, input_package, expected_key, expected_version, expected_inherit - ): - """Test that parse_plugin_key correctly parses valid OCI package formats.""" - plugin_key, version, inherit_version, _ = oci_merger.parse_plugin_key(input_package) - - assert plugin_key == expected_key, f"Expected key {expected_key}, got {plugin_key}" - assert version == expected_version, f"Expected version {expected_version}, got {version}" - assert inherit_version == expected_inherit, f"Expected inherit {expected_inherit}, got {inherit_version}" - - @pytest.mark.parametrize("invalid_package,error_substring", [ - # Missing tag/digest - ('oci://registry.io/plugin!path', 'not in the expected format'), - ('oci://registry.io/plugin', 'not in the expected format'), - ('oci://host:1000/path', 'not in the expected format'), - - # Invalid format - no tag or digest before ! - ('oci://registry.io!path', 'not in the expected format'), - ('oci://host:1000!path', 'not in the expected format'), - - # Invalid digest algorithm (md5 not in RECOGNIZED_ALGORITHMS) - ('oci://registry.io/plugin@md5:abc123!plugin', 'not in the expected format'), - ('oci://host:1000/path@md5:abc123!plugin', 'not in the expected format'), - - # Invalid format - multiple @ symbols - ('oci://registry.io/plugin@@sha256:abc!plugin', 'not in the expected format'), - ('oci://host:1000/path@@sha256:abc!plugin', 'not in the expected format'), - - # Invalid format - multiple : symbols in tag - ('oci://registry.io/plugin:v1:v2!plugin', 'not in the expected format'), - ('oci://host:1000/path:v1:v2!plugin', 'not in the expected format'), - - # Empty tag - ('oci://registry.io/plugin:!plugin', 'not in the expected format'), - ('oci://registry.io/plugin:', 'not in the expected format'), - ('oci://host:1000/path:!plugin', 'not in the expected format'), - ('oci://host:1000/path:', 'not in the expected format'), - - # Empty path after ! - ('oci://registry.io/plugin:v1.0!', 'not in the expected format'), - ('oci://host:1000/path:v1.0!', 'not in the expected format'), - - # No oci:// prefix (but this should fail the regex) - ('registry.io/plugin:v1.0!plugin', 'not in the expected format'), - ('registry.io/plugin:v1.0', 'not in the expected format'), - ('host:1000/path:v1.0!plugin', 'not in the expected format'), - ('host:1000/path:v1.0', 'not in the expected format'), - - # Non-numeric port - ('oci://host:abc/path:tag!plugin', 'not in the expected format'), - ('oci://host:abc/path', 'not in the expected format'), - ('oci://10.0.0.1:abc/path', 'not in the expected format'), # NOSONAR - ('oci://10.0.0.1:abc/path:tag!plugin', 'not in the expected format'), # NOSONAR - ]) - def test_parse_plugin_key_error_cases(self, oci_merger, invalid_package, error_substring): - """Test that parse_plugin_key raises InstallException for invalid OCI package formats.""" - with pytest.raises(InstallException) as exc_info: - oci_merger.parse_plugin_key(invalid_package) - - assert error_substring in str(exc_info.value), \ - f"Expected error message to contain '{error_substring}', got: {str(exc_info.value)}" - - def test_parse_plugin_key_complex_digest(self, oci_merger): - """Test parsing OCI package with complex digest value.""" - # Note: The pattern allows any value after @ including special strings like {{inherit}} - # though this would be semantically incorrect for digest format - input_pkg = 'oci://registry.io/plugin@sha256:abc123def456789!plugin' - plugin_key, version, inherit, resolved_path = oci_merger.parse_plugin_key(input_pkg) - - assert plugin_key == 'oci://registry.io/plugin:!plugin' - assert version == 'sha256:abc123def456789' - assert inherit is False - assert resolved_path == 'plugin' - - def test_parse_plugin_key_strips_version_from_key(self, oci_merger): - """Test that the plugin key does not contain version information.""" - input_pkg = 'oci://quay.io/user/plugin:v1.0.0!my-plugin' - plugin_key, version, _, resolved_path = oci_merger.parse_plugin_key(input_pkg) - - # The key should not contain the version - assert ':v1.0.0' not in plugin_key - assert plugin_key == 'oci://quay.io/user/plugin:!my-plugin' - # But the version should be returned separately - assert version == 'v1.0.0' - assert resolved_path == 'my-plugin' - - def test_parse_plugin_key_with_nested_path(self, oci_merger): - """Test parsing OCI package with nested path after !.""" - input_pkg = 'oci://registry.io/plugin:v1.0!path/to/nested/plugin' - plugin_key, version, inherit, resolved_path = oci_merger.parse_plugin_key(input_pkg) - - assert plugin_key == 'oci://registry.io/plugin:!path/to/nested/plugin' - assert version == 'v1.0' - assert inherit is False - assert resolved_path == 'path/to/nested/plugin' - - def test_parse_plugin_key_auto_detect_single_plugin(self, oci_merger, mocker): - """Test auto-detection with single plugin in OCI image.""" - # Mock get_oci_plugin_paths to return single plugin - mock_get_paths = mocker.patch.object(install_dynamic_plugins, 'get_oci_plugin_paths') - mock_get_paths.return_value = ['auto-detected-plugin'] - - input_pkg = 'oci://registry.io/plugin:v1.0' - plugin_key, version, inherit, resolved_path = oci_merger.parse_plugin_key(input_pkg) - - # Should resolve to the auto-detected plugin name - assert plugin_key == 'oci://registry.io/plugin:!auto-detected-plugin' - assert version == 'v1.0' - assert inherit is False - assert resolved_path == 'auto-detected-plugin' - - # Package should NOT be updated here (that happens in merge_plugin) - # The fixture has a different initial package which should remain unchanged - assert oci_merger.plugin['package'] == 'oci://example.com:v1.0!plugin' - - # Verify get_oci_plugin_paths was called - mock_get_paths.assert_called_once_with('oci://registry.io/plugin:v1.0') - - def test_parse_plugin_key_auto_detect_with_digest(self, oci_merger, mocker): - """Test auto-detection with digest-based reference.""" - mock_get_paths = mocker.patch.object(install_dynamic_plugins, 'get_oci_plugin_paths') - mock_get_paths.return_value = ['my-plugin'] - - input_pkg = 'oci://registry.io/plugin@sha256:abc123' - plugin_key, version, inherit, resolved_path = oci_merger.parse_plugin_key(input_pkg) - - assert plugin_key == 'oci://registry.io/plugin:!my-plugin' - assert version == 'sha256:abc123' - assert inherit is False - assert resolved_path == 'my-plugin' - - # Package should NOT be updated here (that happens in merge_plugin) - # The fixture has a different initial package which should remain unchanged - assert oci_merger.plugin['package'] == 'oci://example.com:v1.0!plugin' - - def test_parse_plugin_key_auto_detect_no_plugins_error(self, oci_merger, mocker): - """Test error when no plugins found in OCI image.""" - mock_get_paths = mocker.patch.object(install_dynamic_plugins, 'get_oci_plugin_paths') - mock_get_paths.return_value = [] - - with pytest.raises(InstallException) as exc_info: - oci_merger.parse_plugin_key('oci://registry.io/plugin:v1.0') - - assert 'No plugins found' in str(exc_info.value) - - def test_parse_plugin_key_auto_detect_multiple_plugins_error(self, oci_merger, mocker): - """Test error when multiple plugins found without explicit path.""" - mock_get_paths = mocker.patch.object(install_dynamic_plugins, 'get_oci_plugin_paths') - mock_get_paths.return_value = ['plugin-one', 'plugin-two', 'plugin-three'] - - with pytest.raises(InstallException) as exc_info: - oci_merger.parse_plugin_key('oci://registry.io/plugin:v1.0') - - error_msg = str(exc_info.value) - assert 'Multiple plugins found' in error_msg - assert 'plugin-one' in error_msg - assert 'plugin-two' in error_msg - assert 'plugin-three' in error_msg - - def test_parse_plugin_key_inherit_without_path_returns_registry(self, oci_merger): - """Test that {{inherit}} without explicit path returns registry as key with None for path.""" - plugin_key, version, inherit_version, resolved_path = oci_merger.parse_plugin_key('oci://registry.io/plugin:{{inherit}}') - - # Should return registry as the temporary key - assert plugin_key == 'oci://registry.io/plugin' - assert version == '{{inherit}}' - assert inherit_version is True - assert resolved_path is None # Path will be resolved during merge_plugin() - -class TestEdgeCases: - """Test edge cases and boundary conditions.""" - - def test_npm_merger_empty_string(self): - """Test NPM merger with empty package string.""" - plugin = {'package': ''} - merger = NPMPackageMerger(plugin, 'test.yaml', {}) - result = merger.parse_plugin_key('') - assert result == '' - - def test_npm_merger_special_characters_in_package(self): - """Test NPM packages with special characters.""" - plugin = {'package': 'test'} - merger = NPMPackageMerger(plugin, 'test.yaml', {}) - - # Package name with underscores and hyphens - result = merger.parse_plugin_key('my_special-package@1.0.0') - assert result == 'my_special-package' - - def test_oci_merger_long_digest(self): - """Test OCI package with realistic long SHA256 digest.""" - plugin = {'package': 'oci://example.com:v1!plugin'} - merger = OciPackageMerger(plugin, 'test.yaml', {}) - - long_digest = 'sha256:' + 'a' * 64 - input_pkg = f'oci://quay.io/user/plugin@{long_digest}!plugin' - plugin_key, version, inherit, resolved_path = merger.parse_plugin_key(input_pkg) - - assert plugin_key == 'oci://quay.io/user/plugin:!plugin' - assert version == long_digest - assert inherit is False - assert resolved_path == 'plugin' - - -class TestNPMPackageMergerMergePlugin: - """Test cases for NPMPackageMerger.merge_plugin() method.""" - - def test_add_new_plugin_level_0(self): - """Test adding a new plugin at level 0.""" - all_plugins = {} - plugin = {'package': 'test-package@1.0.0', 'disabled': False} - merger = NPMPackageMerger(plugin, 'test-file.yaml', all_plugins) - - merger.merge_plugin(level=0) - - # Check plugin was added - assert 'test-package' in all_plugins - assert all_plugins['test-package']['package'] == 'test-package@1.0.0' - assert all_plugins['test-package']['disabled'] is False - assert all_plugins['test-package']['last_modified_level'] == 0 - - def test_override_plugin_level_0_to_1(self): - """Test overriding a plugin from level 0 to level 1.""" - all_plugins = {} - - # Add plugin at level 0 - plugin1 = {'package': 'test-package@1.0.0', 'disabled': False} - merger1 = NPMPackageMerger(plugin1, 'included-file.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Override at level 1 - plugin2 = {'package': 'test-package@2.0.0', 'disabled': True} - merger2 = NPMPackageMerger(plugin2, 'main-file.yaml', all_plugins) - merger2.merge_plugin(level=1) - - # Check override succeeded - assert all_plugins['test-package']['disabled'] is True - assert all_plugins['test-package']['last_modified_level'] == 1 - # Package field should be overridden - assert all_plugins['test-package']['package'] == 'test-package@2.0.0' - - def test_override_multiple_config_fields(self): - """Test overriding multiple plugin config fields.""" - all_plugins = {} - - # Add plugin at level 0 - plugin1 = { - 'package': '@scope/plugin@1.0.0', - 'disabled': False, - 'pullPolicy': 'IfNotPresent', - 'pluginConfig': {'key1': 'value1'} - } - merger1 = NPMPackageMerger(plugin1, 'included-file.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Override at level 1 - plugin2 = { - 'package': '@scope/plugin@2.0.0', - 'disabled': True, - 'pullPolicy': 'Always', - 'pluginConfig': {'key2': 'value2'}, - 'integrity': 'sha256-abc123' - } - merger2 = NPMPackageMerger(plugin2, 'main-file.yaml', all_plugins) - merger2.merge_plugin(level=1) - - # Check all fields were updated except package - assert all_plugins['@scope/plugin']['disabled'] is True - assert all_plugins['@scope/plugin']['pullPolicy'] == 'Always' - assert all_plugins['@scope/plugin']['pluginConfig'] == {'key2': 'value2'} - assert all_plugins['@scope/plugin']['integrity'] == 'sha256-abc123' - # Package field not overridden - assert all_plugins['@scope/plugin']['package'] == '@scope/plugin@2.0.0' - - def test_duplicate_plugin_same_level_0_raises_error(self): - """Test that duplicate plugin at same level 0 raises InstallException.""" - all_plugins = {} - - # Add plugin at level 0 - plugin1 = {'package': 'duplicate-package@1.0.0'} - merger1 = NPMPackageMerger(plugin1, 'included-file.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Try to add same plugin again at level 0 - plugin2 = {'package': 'duplicate-package@2.0.0'} - merger2 = NPMPackageMerger(plugin2, 'included-file.yaml', all_plugins) - - with pytest.raises(InstallException) as exc_info: - merger2.merge_plugin(level=0) - - assert 'Duplicate plugin configuration' in str(exc_info.value) - assert 'duplicate-package@2.0.0' in str(exc_info.value) - - def test_duplicate_plugin_same_level_1_raises_error(self): - """Test that duplicate plugin at same level 1 raises InstallException.""" - all_plugins = {} - - # Add plugin at level 0 - plugin1 = {'package': 'test-package@1.0.0'} - merger1 = NPMPackageMerger(plugin1, 'included-file.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Override at level 1 - plugin2 = {'package': 'test-package@2.0.0'} - merger2 = NPMPackageMerger(plugin2, 'main-file.yaml', all_plugins) - merger2.merge_plugin(level=1) - - # Try to add same plugin again at level 1 - plugin3 = {'package': 'test-package@3.0.0'} - merger3 = NPMPackageMerger(plugin3, 'main-file.yaml', all_plugins) - - with pytest.raises(InstallException) as exc_info: - merger3.merge_plugin(level=1) - - assert 'Duplicate plugin configuration' in str(exc_info.value) - - def test_invalid_package_field_type_raises_error(self): - """Test that non-string package field raises InstallException.""" - all_plugins = {} - plugin = {'package': 123} - merger = NPMPackageMerger(plugin, 'test-file.yaml', all_plugins) - - with pytest.raises(InstallException) as exc_info: - merger.merge_plugin(level=0) - - assert 'must be a string' in str(exc_info.value) - - def test_version_stripping_in_plugin_key(self): - """Test that version is stripped from plugin key.""" - all_plugins = {} - - # Add plugin with version - plugin1 = {'package': 'my-plugin@1.0.0'} - merger1 = NPMPackageMerger(plugin1, 'test-file.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Override with different version - plugin2 = {'package': 'my-plugin@2.0.0', 'disabled': True} - merger2 = NPMPackageMerger(plugin2, 'test-file.yaml', all_plugins) - merger2.merge_plugin(level=1) - - # Both should map to same key - assert 'my-plugin' in all_plugins - assert all_plugins['my-plugin']['disabled'] is True - - -class TestOciPackageMergerMergePlugin: - """Test cases for OciPackageMerger.merge_plugin() method.""" - - def test_add_new_plugin_with_tag(self): - """Test adding a new OCI plugin with tag.""" - all_plugins = {} - plugin = {'package': 'oci://registry.io/plugin:v1.0!path'} - merger = OciPackageMerger(plugin, 'test-file.yaml', all_plugins) - - merger.merge_plugin(level=0) - - plugin_key = 'oci://registry.io/plugin:!path' - assert plugin_key in all_plugins - assert all_plugins[plugin_key]['package'] == 'oci://registry.io/plugin:v1.0!path' - assert all_plugins[plugin_key]['version'] == 'v1.0' - assert all_plugins[plugin_key]['last_modified_level'] == 0 - - def test_add_new_plugin_with_digest(self): - """Test adding a new OCI plugin with digest.""" - all_plugins = {} - plugin = {'package': 'oci://registry.io/plugin@sha256:abc123!path'} - merger = OciPackageMerger(plugin, 'test-file.yaml', all_plugins) - - merger.merge_plugin(level=0) - - plugin_key = 'oci://registry.io/plugin:!path' - assert plugin_key in all_plugins - assert all_plugins[plugin_key]['version'] == 'sha256:abc123' - - def test_merge_plugin_auto_detect_updates_package(self, mocker): - """Test that merge_plugin updates package when path is auto-detected.""" - # Mock get_oci_plugin_paths to return single plugin - mock_get_paths = mocker.patch.object(install_dynamic_plugins, 'get_oci_plugin_paths') - mock_get_paths.return_value = ['detected-plugin'] - - all_plugins = {} - # Package without explicit path (will be auto-detected) - plugin = {'package': 'oci://registry.io/plugin:v1.0'} - merger = OciPackageMerger(plugin, 'test-file.yaml', all_plugins) - - merger.merge_plugin(level=0) - - # Verify the package was updated with the resolved path - plugin_key = 'oci://registry.io/plugin:!detected-plugin' - assert plugin_key in all_plugins - assert all_plugins[plugin_key]['package'] == 'oci://registry.io/plugin:v1.0!detected-plugin' - assert all_plugins[plugin_key]['version'] == 'v1.0' - - # Original plugin dict should also be updated - assert merger.plugin['package'] == 'oci://registry.io/plugin:v1.0!detected-plugin' - - def test_merge_plugin_auto_detect_with_digest_updates_package(self, mocker): - """Test that merge_plugin updates package with digest when path is auto-detected.""" - # Mock get_oci_plugin_paths to return single plugin - mock_get_paths = mocker.patch.object(install_dynamic_plugins, 'get_oci_plugin_paths') - mock_get_paths.return_value = ['my-plugin'] - - all_plugins = {} - # Package without explicit path (will be auto-detected) - plugin = {'package': 'oci://registry.io/plugin@sha256:abc123'} - merger = OciPackageMerger(plugin, 'test-file.yaml', all_plugins) - - merger.merge_plugin(level=0) - - # Verify the package was updated with the resolved path - plugin_key = 'oci://registry.io/plugin:!my-plugin' - assert plugin_key in all_plugins - assert all_plugins[plugin_key]['package'] == 'oci://registry.io/plugin@sha256:abc123!my-plugin' - assert all_plugins[plugin_key]['version'] == 'sha256:abc123' - - # Original plugin dict should also be updated - assert merger.plugin['package'] == 'oci://registry.io/plugin@sha256:abc123!my-plugin' - - def test_override_plugin_version(self, capsys): - """Test overriding OCI plugin version from level 0 to 1.""" - all_plugins = {} - - # Add plugin at level 0 - plugin1 = {'package': 'oci://registry.io/plugin:v1.0!path'} - merger1 = OciPackageMerger(plugin1, 'included-file.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Override at level 1 with new version - plugin2 = {'package': 'oci://registry.io/plugin:v2.0!path'} - merger2 = OciPackageMerger(plugin2, 'main-file.yaml', all_plugins) - merger2.merge_plugin(level=1) - - # Check version was updated - plugin_key = 'oci://registry.io/plugin:!path' - assert all_plugins[plugin_key]['version'] == 'v2.0' - assert all_plugins[plugin_key]['package'] == 'oci://registry.io/plugin:v2.0!path' - assert all_plugins[plugin_key]['last_modified_level'] == 1 - - # Check that override message was printed - captured = capsys.readouterr() - assert 'Overriding version' in captured.out - assert 'v1.0' in captured.out - assert 'v2.0' in captured.out - - def test_use_inherit_to_preserve_version(self): - """Test using {{inherit}} to preserve existing version.""" - all_plugins = {} - - # Add plugin at level 0 - plugin1 = {'package': 'oci://registry.io/plugin:v1.0!path'} - merger1 = OciPackageMerger(plugin1, 'included-file.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Override at level 1 with {{inherit}} - plugin2 = {'package': 'oci://registry.io/plugin:{{inherit}}!path', 'disabled': True} - merger2 = OciPackageMerger(plugin2, 'main-file.yaml', all_plugins) - merger2.merge_plugin(level=1) - - # Check version was preserved - plugin_key = 'oci://registry.io/plugin:!path' - assert all_plugins[plugin_key]['version'] == 'v1.0' - # Package field should NOT be updated when inheriting - assert all_plugins[plugin_key]['package'] == 'oci://registry.io/plugin:v1.0!path' - # But other config should be updated - assert all_plugins[plugin_key]['disabled'] is True - - def test_override_config_with_version_inheritance(self): - """Test overriding plugin config while preserving version with {{inherit}}.""" - all_plugins = {} - - # Add plugin at level 0 - plugin1 = { - 'package': 'oci://registry.io/plugin:v1.0!path', - 'pluginConfig': {'key1': 'value1'} - } - merger1 = OciPackageMerger(plugin1, 'included-file.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Override config at level 1 with {{inherit}} - plugin2 = { - 'package': 'oci://registry.io/plugin:{{inherit}}!path', - 'pluginConfig': {'key2': 'value2'} - } - merger2 = OciPackageMerger(plugin2, 'main-file.yaml', all_plugins) - merger2.merge_plugin(level=1) - - # Check version preserved and config updated - plugin_key = 'oci://registry.io/plugin:!path' - assert all_plugins[plugin_key]['version'] == 'v1.0' - assert all_plugins[plugin_key]['pluginConfig'] == {'key2': 'value2'} - - def test_override_config_without_version_inheritance(self): - """Test overriding both version and config.""" - all_plugins = {} - - # Add plugin at level 0 - plugin1 = { - 'package': 'oci://registry.io/plugin:v1.0!path', - 'pluginConfig': {'key1': 'value1'} - } - merger1 = OciPackageMerger(plugin1, 'included-file.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Override both at level 1 - plugin2 = { - 'package': 'oci://registry.io/plugin:v2.0!path', - 'pluginConfig': {'key2': 'value2'} - } - merger2 = OciPackageMerger(plugin2, 'main-file.yaml', all_plugins) - merger2.merge_plugin(level=1) - - # Check both were updated - plugin_key = 'oci://registry.io/plugin:!path' - assert all_plugins[plugin_key]['version'] == 'v2.0' - assert all_plugins[plugin_key]['pluginConfig'] == {'key2': 'value2'} - assert all_plugins[plugin_key]['package'] == 'oci://registry.io/plugin:v2.0!path' - - def test_override_from_tag_to_digest(self): - """Test overriding from tag to digest.""" - all_plugins = {} - - # Add plugin with tag at level 0 - plugin1 = {'package': 'oci://registry.io/plugin:v1.0!path'} - merger1 = OciPackageMerger(plugin1, 'included-file.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Override with digest at level 1 - plugin2 = {'package': 'oci://registry.io/plugin@sha256:abc123def456!path'} - merger2 = OciPackageMerger(plugin2, 'main-file.yaml', all_plugins) - merger2.merge_plugin(level=1) - - # Check version updated to digest format - plugin_key = 'oci://registry.io/plugin:!path' - assert all_plugins[plugin_key]['version'] == 'sha256:abc123def456' - assert all_plugins[plugin_key]['package'] == 'oci://registry.io/plugin@sha256:abc123def456!path' - - def test_new_plugin_with_inherit_raises_error(self): - """Test that using {{inherit}} on a new plugin raises InstallException.""" - all_plugins = {} - plugin = {'package': 'oci://registry.io/plugin:{{inherit}}!path'} - merger = OciPackageMerger(plugin, 'test-file.yaml', all_plugins) - - with pytest.raises(InstallException) as exc_info: - merger.merge_plugin(level=0) - - assert '{{inherit}}' in str(exc_info.value) - assert 'no resolved tag or digest' in str(exc_info.value) - - def test_duplicate_oci_plugin_same_level_0_raises_error(self): - """Test that duplicate OCI plugin at same level 0 raises InstallException.""" - all_plugins = {} - - # Add plugin at level 0 - plugin1 = {'package': 'oci://registry.io/plugin:v1.0!path'} - merger1 = OciPackageMerger(plugin1, 'included-file.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Try to add same plugin again at level 0 - plugin2 = {'package': 'oci://registry.io/plugin:v2.0!path'} - merger2 = OciPackageMerger(plugin2, 'included-file.yaml', all_plugins) - - with pytest.raises(InstallException) as exc_info: - merger2.merge_plugin(level=0) - - assert 'Duplicate plugin configuration' in str(exc_info.value) - - def test_duplicate_oci_plugin_same_level_1_raises_error(self): - """Test that duplicate OCI plugin at same level 1 raises InstallException.""" - all_plugins = {} - - # Add plugin at level 0 - plugin1 = {'package': 'oci://registry.io/plugin:v1.0!path'} - merger1 = OciPackageMerger(plugin1, 'included-file.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Override at level 1 - plugin2 = {'package': 'oci://registry.io/plugin:v2.0!path'} - merger2 = OciPackageMerger(plugin2, 'main-file.yaml', all_plugins) - merger2.merge_plugin(level=1) - - # Try to add same plugin again at level 1 - plugin3 = {'package': 'oci://registry.io/plugin:v3.0!path'} - merger3 = OciPackageMerger(plugin3, 'main-file.yaml', all_plugins) - - with pytest.raises(InstallException) as exc_info: - merger3.merge_plugin(level=1) - - assert 'Duplicate plugin configuration' in str(exc_info.value) - - def test_invalid_package_field_type_raises_error(self): - """Test that non-string package field raises InstallException.""" - all_plugins = {} - plugin = {'package': ['not', 'a', 'string']} - merger = OciPackageMerger(plugin, 'test-file.yaml', all_plugins) - - with pytest.raises(InstallException) as exc_info: - merger.merge_plugin(level=0) - - assert 'must be a string' in str(exc_info.value) - - -class TestOciInheritWithPathOmission: - """Test cases for {{inherit}} with path omission feature.""" - - def test_inherit_version_and_path_from_single_base_plugin(self, capsys): - """Test inheriting both version and path when exactly one base plugin exists.""" - all_plugins = {} - - # Add base plugin at level 0 with explicit version and path - plugin1 = { - 'package': 'oci://registry.io/plugin:v1.0!my-plugin', - 'disabled': False - } - - merger1 = OciPackageMerger(plugin1, 'base.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Override at level 1 using {{inherit}} without path - plugin2 = { - 'package': 'oci://registry.io/plugin:{{inherit}}', - 'disabled': True - } - merger2 = OciPackageMerger(plugin2, 'main.yaml', all_plugins) - merger2.merge_plugin(level=1) - - # Check that version and path were inherited - plugin_key = 'oci://registry.io/plugin:!my-plugin' - assert plugin_key in all_plugins - assert all_plugins[plugin_key]['version'] == 'v1.0' - assert all_plugins[plugin_key]['package'] == 'oci://registry.io/plugin:v1.0!my-plugin' - assert all_plugins[plugin_key]['disabled'] is True - - # Check that inheritance message was printed - captured = capsys.readouterr() - assert 'Inheriting version `v1.0` and plugin path `my-plugin`' in captured.out - - def test_inherit_version_and_path_with_digest(self, capsys): - """Test inheriting version (digest) and path from base plugin.""" - all_plugins = {} - - # Add base plugin with digest - plugin1 = {'package': 'oci://registry.io/plugin@sha256:abc123!plugin-name'} - merger1 = OciPackageMerger(plugin1, 'base.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Inherit using {{inherit}} without path - plugin2 = { - 'package': 'oci://registry.io/plugin:{{inherit}}', - 'pluginConfig': {'custom': 'config'} - } - merger2 = OciPackageMerger(plugin2, 'main.yaml', all_plugins) - merger2.merge_plugin(level=1) - - # Check inheritance - plugin_key = 'oci://registry.io/plugin:!plugin-name' - assert all_plugins[plugin_key]['version'] == 'sha256:abc123' - assert all_plugins[plugin_key]['package'] == 'oci://registry.io/plugin@sha256:abc123!plugin-name' - assert all_plugins[plugin_key]['pluginConfig'] == {'custom': 'config'} - - def test_inherit_from_auto_detected_base_plugin(self, mocker, capsys): - """Test inheriting from a base plugin that had its path auto-detected.""" - # Mock get_oci_plugin_paths to return single plugin - mock_get_paths = mocker.patch.object(install_dynamic_plugins, 'get_oci_plugin_paths') - mock_get_paths.return_value = ['auto-detected-plugin'] - - all_plugins = {} - - # Add base plugin without explicit path (will auto-detect) - plugin1 = {'package': 'oci://registry.io/plugin:v1.0'} - merger1 = OciPackageMerger(plugin1, 'base.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Inherit both version AND the auto-detected path - plugin2 = {'package': 'oci://registry.io/plugin:{{inherit}}'} - merger2 = OciPackageMerger(plugin2, 'main.yaml', all_plugins) - merger2.merge_plugin(level=1) - - # Check that auto-detected path was inherited - plugin_key = 'oci://registry.io/plugin:!auto-detected-plugin' - assert plugin_key in all_plugins - assert all_plugins[plugin_key]['version'] == 'v1.0' - assert all_plugins[plugin_key]['package'] == 'oci://registry.io/plugin:v1.0!auto-detected-plugin' - - captured = capsys.readouterr() - assert 'Inheriting version `v1.0` and plugin path `auto-detected-plugin`' in captured.out - - def test_inherit_without_path_no_base_plugin_error(self): - """Test error when using {{inherit}} without path but no base plugin exists.""" - all_plugins = {} - - # Try to use {{inherit}} without any base plugin - plugin = {'package': 'oci://registry.io/plugin:{{inherit}}'} - merger = OciPackageMerger(plugin, 'main.yaml', all_plugins) - - with pytest.raises(InstallException) as exc_info: - merger.merge_plugin(level=0) - - error_msg = str(exc_info.value) - assert '{{inherit}}' in error_msg - assert 'no existing plugin configuration found' in error_msg - assert 'oci://registry.io/plugin' in error_msg - - def test_inherit_without_path_multiple_plugins_error(self): - """Test error when using {{inherit}} without path with multiple base plugins from same image.""" - all_plugins = {} - - # Add two plugins from same image at level 0 - plugin1 = {'package': 'oci://registry.io/bundle:v1.0!plugin-a'} - merger1 = OciPackageMerger(plugin1, 'base.yaml', all_plugins) - merger1.merge_plugin(level=0) - - plugin2 = {'package': 'oci://registry.io/bundle:v1.0!plugin-b'} - merger2 = OciPackageMerger(plugin2, 'base.yaml', all_plugins) - merger2.merge_plugin(level=0) - - # Try to use {{inherit}} without specifying which plugin - plugin3 = {'package': 'oci://registry.io/bundle:{{inherit}}'} - merger3 = OciPackageMerger(plugin3, 'main.yaml', all_plugins) - - with pytest.raises(InstallException) as exc_info: - merger3.merge_plugin(level=1) - - error_msg = str(exc_info.value) - assert '{{inherit}}' in error_msg - assert 'multiple plugins from this image are defined' in error_msg - assert 'oci://registry.io/bundle:v1.0!plugin-a' in error_msg - assert 'oci://registry.io/bundle:v1.0!plugin-b' in error_msg - assert '{{inherit}}!' in error_msg - - def test_inherit_without_path_works_with_explicit_path_too(self): - """Test that {{inherit}} with explicit path still works alongside path omission.""" - all_plugins = {} - - # Add two plugins from same image - plugin1 = {'package': 'oci://registry.io/bundle:v1.0!plugin-a'} - merger1 = OciPackageMerger(plugin1, 'base.yaml', all_plugins) - merger1.merge_plugin(level=0) - - plugin2 = {'package': 'oci://registry.io/bundle:v1.0!plugin-b'} - merger2 = OciPackageMerger(plugin2, 'base.yaml', all_plugins) - merger2.merge_plugin(level=0) - - # Use {{inherit}} with explicit path for plugin-a - plugin3 = { - 'package': 'oci://registry.io/bundle:{{inherit}}!plugin-a', - 'disabled': True - } - merger3 = OciPackageMerger(plugin3, 'main.yaml', all_plugins) - merger3.merge_plugin(level=1) - - # Should successfully override plugin-a only - assert all_plugins['oci://registry.io/bundle:!plugin-a']['disabled'] is True - assert all_plugins['oci://registry.io/bundle:!plugin-a']['version'] == 'v1.0' - # plugin-b should be unchanged - assert 'disabled' not in all_plugins['oci://registry.io/bundle:!plugin-b'] - - def test_inherit_path_omission_preserves_other_fields(self): - """Test that path inheritance preserves and overrides other plugin fields correctly.""" - all_plugins = {} - - # Add base plugin with various fields - plugin1 = { - 'package': 'oci://registry.io/plugin:v1.0!my-plugin', - 'pluginConfig': {'base': 'config'}, - 'disabled': False, - 'pullPolicy': 'IfNotPresent' - } - merger1 = OciPackageMerger(plugin1, 'base.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Override with {{inherit}} and new config - plugin2 = { - 'package': 'oci://registry.io/plugin:{{inherit}}', - 'pluginConfig': {'override': 'config'}, - 'disabled': True - } - merger2 = OciPackageMerger(plugin2, 'main.yaml', all_plugins) - merger2.merge_plugin(level=1) - - plugin_key = 'oci://registry.io/plugin:!my-plugin' - # Version and path inherited - assert all_plugins[plugin_key]['version'] == 'v1.0' - assert all_plugins[plugin_key]['package'] == 'oci://registry.io/plugin:v1.0!my-plugin' - # Fields overridden - assert all_plugins[plugin_key]['pluginConfig'] == {'override': 'config'} - assert all_plugins[plugin_key]['disabled'] is True - assert all_plugins[plugin_key]['pullPolicy'] == 'IfNotPresent' - - def test_inherit_path_omission_updates_package_field(self): - """Test that path inheritance correctly updates the plugin package field.""" - all_plugins = {} - - # Add base plugin at level 0 - plugin1 = {'package': 'oci://registry.io/plugin:v1.5.2!my-plugin-name'} - merger1 = OciPackageMerger(plugin1, 'base.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Inherit at level 1 - package field should be updated with inherited values - plugin2 = {'package': 'oci://registry.io/plugin:{{inherit}}'} - merger2 = OciPackageMerger(plugin2, 'main.yaml', all_plugins) - merger2.merge_plugin(level=1) - - plugin_key = 'oci://registry.io/plugin:!my-plugin-name' - # The plugin package field should now have the resolved version and path - assert all_plugins[plugin_key]['package'] == 'oci://registry.io/plugin:v1.5.2!my-plugin-name' - # The original plugin2 object should also be updated - assert merger2.plugin['package'] == 'oci://registry.io/plugin:v1.5.2!my-plugin-name' - - -class TestPluginInstallerShouldSkipInstallation: - """Test cases for PluginInstaller.should_skip_installation() method.""" - - def test_plugin_not_installed_returns_false(self, tmp_path): - """Test that plugin not in hash dict returns False.""" - plugin = {'plugin_hash': 'abc123', 'package': 'test-pkg'} - plugin_path_by_hash = {} # Empty - nothing installed - installer = install_dynamic_plugins.NpmPluginInstaller(str(tmp_path)) - - should_skip, reason = installer.should_skip_installation(plugin, plugin_path_by_hash) - - assert should_skip is False - assert reason == "not_installed" - - def test_plugin_installed_if_not_present_skips(self, tmp_path): - """Test that installed plugin with IF_NOT_PRESENT policy skips.""" - plugin = { - 'plugin_hash': 'abc123', - 'package': 'test-pkg', - 'pullPolicy': 'IfNotPresent' - } - plugin_path_by_hash = {'abc123': 'test-pkg-1.0.0'} - installer = install_dynamic_plugins.NpmPluginInstaller(str(tmp_path)) - - should_skip, reason = installer.should_skip_installation(plugin, plugin_path_by_hash) - - assert should_skip is True - assert reason == "already_installed" - - def test_plugin_installed_always_policy_forces_download(self, tmp_path): - """Test that ALWAYS policy forces download.""" - plugin = { - 'plugin_hash': 'abc123', - 'package': 'test-pkg', - 'pullPolicy': 'Always' - } - plugin_path_by_hash = {'abc123': 'test-pkg-1.0.0'} - installer = install_dynamic_plugins.NpmPluginInstaller(str(tmp_path)) - - should_skip, reason = installer.should_skip_installation(plugin, plugin_path_by_hash) - - assert should_skip is False - assert reason == "force_download" - - def test_plugin_installed_force_download_flag(self, tmp_path): - """Test that forceDownload flag forces download.""" - plugin = { - 'plugin_hash': 'abc123', - 'package': 'test-pkg', - 'forceDownload': True - } - plugin_path_by_hash = {'abc123': 'test-pkg-1.0.0'} - installer = install_dynamic_plugins.NpmPluginInstaller(str(tmp_path)) - - should_skip, reason = installer.should_skip_installation(plugin, plugin_path_by_hash) - - assert should_skip is False - assert reason == "force_download" - - def test_default_pull_policy_if_not_present(self, tmp_path): - """Test that default pull policy is IF_NOT_PRESENT.""" - plugin = {'plugin_hash': 'abc123', 'package': 'test-pkg'} # No pullPolicy - plugin_path_by_hash = {'abc123': 'test-pkg-1.0.0'} - installer = install_dynamic_plugins.NpmPluginInstaller(str(tmp_path)) - - should_skip, reason = installer.should_skip_installation(plugin, plugin_path_by_hash) - - assert should_skip is True - assert reason == "already_installed" - - -class TestOciPluginInstallerShouldSkipInstallation: - """Test cases for OciPluginInstaller.should_skip_installation() method.""" - - def test_plugin_not_installed_returns_false(self, tmp_path, mocker): - """Test that plugin not in hash dict returns False.""" - plugin = { - 'plugin_hash': 'abc123', - 'package': 'oci://registry.io/plugin:latest!path' - } - plugin_path_by_hash = {} - - # Mock OciDownloader - mock_downloader = mocker.MagicMock() - installer = install_dynamic_plugins.OciPluginInstaller(str(tmp_path)) - installer.downloader = mock_downloader - - should_skip, reason = installer.should_skip_installation(plugin, plugin_path_by_hash) - - assert should_skip is False - assert reason == "not_installed" - - def test_always_policy_unchanged_digest_skips(self, tmp_path, mocker): - """Test that ALWAYS policy with unchanged digest skips download.""" - plugin_path = 'plugin-dir' - plugin = { - 'plugin_hash': 'abc123', - 'package': 'oci://registry.io/plugin:v1.0!path', - 'pullPolicy': 'Always' - } - plugin_path_by_hash = {'abc123': plugin_path} - - # Create digest file with matching digest - digest_file = tmp_path / plugin_path / 'dynamic-plugin-image.hash' - digest_file.parent.mkdir(parents=True) - digest_file.write_text('matching_digest') - - # Mock downloader to return same digest - mock_downloader = mocker.MagicMock() - mock_downloader.digest.return_value = 'matching_digest' - - installer = install_dynamic_plugins.OciPluginInstaller(str(tmp_path)) - installer.downloader = mock_downloader - - should_skip, reason = installer.should_skip_installation(plugin, plugin_path_by_hash) - - assert should_skip is True - assert reason == "digest_unchanged" - - def test_always_policy_changed_digest_forces_download(self, tmp_path, mocker): - """Test that ALWAYS policy with changed digest forces download.""" - plugin_path = 'plugin-dir' - plugin = { - 'plugin_hash': 'abc123', - 'package': 'oci://registry.io/plugin:v1.0!path', - 'pullPolicy': 'Always' - } - plugin_path_by_hash = {'abc123': plugin_path} - - # Create digest file with old digest - digest_file = tmp_path / plugin_path / 'dynamic-plugin-image.hash' - digest_file.parent.mkdir(parents=True) - digest_file.write_text('old_digest') - - # Mock downloader to return different digest - mock_downloader = mocker.MagicMock() - mock_downloader.digest.return_value = 'new_digest' - - installer = install_dynamic_plugins.OciPluginInstaller(str(tmp_path)) - installer.downloader = mock_downloader - - should_skip, reason = installer.should_skip_installation(plugin, plugin_path_by_hash) - - assert should_skip is False - assert reason == "force_download" - def test_if_not_present_policy_skips(self, tmp_path, mocker): - """Test that IF_NOT_PRESENT policy skips.""" - plugin_path = 'plugin-dir' - plugin = { - 'plugin_hash': 'abc123', - 'package': 'oci://registry.io/plugin:v1.0!path', - 'pullPolicy': 'IfNotPresent' - } - plugin_path_by_hash = {'abc123': plugin_path} - - installer = install_dynamic_plugins.OciPluginInstaller(str(tmp_path)) - should_skip, reason = installer.should_skip_installation(plugin, plugin_path_by_hash) - - assert should_skip is True - assert reason == "already_installed" - -class TestNpmPluginInstallerInstall: - """Test cases for NpmPluginInstaller.install() method and verify_package_integrity() (mocked).""" - - def test_missing_integrity_remote_package_raises_exception(self, tmp_path): - """Test that missing integrity for remote package raises exception.""" - plugin = {'package': 'test-package@1.0.0'} # No integrity - plugin_path_by_hash = {} - - installer = install_dynamic_plugins.NpmPluginInstaller(str(tmp_path), skip_integrity_check=False) - - with pytest.raises(InstallException) as exc_info: - installer.install(plugin, plugin_path_by_hash) - - assert 'No integrity hash provided' in str(exc_info.value) - - def test_invalid_integrity_hash_type_raises_exception(self, tmp_path, mocker): - """Test that invalid integrity hash type raises exception.""" - plugin = {'package': 'test-package@1.0.0', 'integrity': 1234567890} - - with pytest.raises(InstallException) as exc_info: - install_dynamic_plugins.verify_package_integrity(plugin, "dummy-archive.tgz") - assert 'must be a string' in str(exc_info.value) - - def test_invalid_integrity_hash_format_raises_exception(self, tmp_path, mocker): - """Test that invalid integrity hash (not of form -) raises exception.""" - plugin = {'package': 'test-package@1.0.0', 'integrity': 'invalidhash'} - - with pytest.raises(InstallException) as exc_info: - install_dynamic_plugins.verify_package_integrity(plugin, "dummy-archive.tgz") - assert 'must be a string of the form' in str(exc_info.value) - - def test_invalid_integrity_algorithm_raises_exception(self, tmp_path, mocker): - """Test that unrecognized integrity algorithm raises exception.""" - plugin = {'package': 'test-package@1.0.0', 'integrity': 'invalidalgo-1234567890abcdef'} - - with pytest.raises(InstallException) as exc_info: - install_dynamic_plugins.verify_package_integrity(plugin, "dummy-archive.tgz") - assert 'is not supported' in str(exc_info.value) - - def test_invalid_integrity_hash_base64_encoding_raises_exception(self, tmp_path, mocker): - """Test invalid base64 encoding in hash triggers exception.""" - plugin = {'package': 'test-package@1.0.0', 'integrity': 'sha256-not@base64!'} - - with pytest.raises(InstallException) as exc_info: - install_dynamic_plugins.verify_package_integrity(plugin, "dummy-archive.tgz") - assert 'is not a valid base64 encoding' in str(exc_info.value) - - def test_integrity_hash_mismatch_raises_exception(self, tmp_path, mocker): - """Test hash verification fails when computed hash does not match.""" - # Valid algorithm and fake base64, but simulated mismatch - import base64 - plugin = {'package': 'test-package@1.0.0', 'integrity': 'sha256-' + base64.b64encode(b'wronghash').decode()} - - with pytest.raises(InstallException) as exc_info: - install_dynamic_plugins.verify_package_integrity(plugin, "dummy-archive.tgz") - assert 'does not match the provided integrity hash' in str(exc_info.value) - def test_skip_integrity_check_flag_works(self, tmp_path, mocker): - """Test that skip_integrity_check flag bypasses integrity check.""" - plugin = {'package': 'test-package@1.0.0'} # No integrity - plugin_path_by_hash = {} - - # Mock npm pack - use string (not bytes) since run_command uses text=True - mock_result = mocker.MagicMock() - mock_result.returncode = 0 - mock_result.stdout = 'test-package-1.0.0.tgz' - mocker.patch('subprocess.run', return_value=mock_result) - - # Mock tarball extraction - mock_tarfile = mocker.patch('tarfile.open') - mock_tar = mocker.MagicMock() - mock_tar.getmembers.return_value = [] - mock_tarfile.return_value.__enter__.return_value = mock_tar - - # Mock file operations - mocker.patch('os.path.exists', return_value=False) - mocker.patch('os.mkdir') - mocker.patch('os.remove') - - installer = install_dynamic_plugins.NpmPluginInstaller(str(tmp_path), skip_integrity_check=True) - plugin_path = installer.install(plugin, plugin_path_by_hash) - - assert plugin_path == 'test-package-1.0.0' - -@pytest.mark.integration -class TestNpmPluginInstallerIntegration: - """Integration tests with real file operations.""" - - @pytest.mark.integration - def test_verify_package_integrity_with_real_tarball(self, tmp_path): - """Test integrity verification with actual openssl commands.""" - import tarfile - import subprocess - import shutil - - # Skip if openssl not available - if not shutil.which('openssl'): - pytest.skip("openssl not available") - - # Create a real test tarball - test_dir = tmp_path / "test-package" - test_dir.mkdir() - (test_dir / "index.js").write_text("console.log('test');") - - tarball_path = tmp_path / "test-package.tgz" - with create_test_tarball(tarball_path) as tar: - tar.add(test_dir, arcname="package") - - # Calculate actual integrity hash using openssl - cat_process = subprocess.Popen(["cat", str(tarball_path)], stdout=subprocess.PIPE) - openssl_dgst = subprocess.Popen( - ["openssl", "dgst", "-sha256", "-binary"], - stdin=cat_process.stdout, - stdout=subprocess.PIPE - ) - openssl_b64 = subprocess.Popen( - ["openssl", "base64", "-A"], - stdin=openssl_dgst.stdout, - stdout=subprocess.PIPE - ) - integrity_hash, _ = openssl_b64.communicate() - integrity_hash = integrity_hash.decode('utf-8').strip() - - # Create plugin with real integrity - plugin = { - 'package': 'test-package', - 'integrity': f'sha256-{integrity_hash}' - } - - # Test verification succeeds with correct hash - install_dynamic_plugins.verify_package_integrity(plugin, str(tarball_path)) - - # Test verification fails with wrong hash (valid base64 but wrong hash) - plugin_wrong = { - 'package': 'test-package', - 'integrity': 'sha256-YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2' - } - - with pytest.raises(InstallException) as exc_info: - install_dynamic_plugins.verify_package_integrity(plugin_wrong, str(tarball_path)) - - assert 'does not match' in str(exc_info.value) - - @pytest.mark.integration - def test_extract_npm_package_with_real_tarball(self, tmp_path): - """Test tarball extraction with real tar file.""" - import tarfile - - # Create a realistic NPM package structure - package_dir = tmp_path / "source" / "package" - package_dir.mkdir(parents=True) - (package_dir / "package.json").write_text('{"name": "test", "version": "1.0.0"}') - (package_dir / "index.js").write_text("module.exports = {};") - (package_dir / "lib").mkdir() - (package_dir / "lib" / "helper.js").write_text("exports.helper = () => {};") - - # Create tarball following NPM format (with 'package/' prefix) - tarball_path = tmp_path / "test-package-1.0.0.tgz" - with create_test_tarball(tarball_path) as tar: - tar.add(package_dir, arcname="package") - - # Test extraction - installer = install_dynamic_plugins.NpmPluginInstaller(str(tmp_path)) - plugin_path = installer._extract_npm_package(str(tarball_path)) - - # Verify extracted files - extracted_dir = tmp_path / "test-package-1.0.0" - assert extracted_dir.exists() - assert (extracted_dir / "package.json").exists() - assert (extracted_dir / "index.js").exists() - assert (extracted_dir / "lib" / "helper.js").exists() - - # Verify tarball was removed - assert not tarball_path.exists() - - @pytest.mark.integration - def test_zip_bomb_protection_real_tarball(self, tmp_path): - """Test that extraction rejects tarballs with oversized files.""" - import tarfile - - # Create a tarball with a file exceeding MAX_ENTRY_SIZE - large_content = b"x" * 25_000_000 # 25MB (exceeds default 20MB) - - package_dir = tmp_path / "source" / "package" - package_dir.mkdir(parents=True) - (package_dir / "huge-file.bin").write_bytes(large_content) - - tarball_path = tmp_path / "malicious.tgz" - 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)) - - with pytest.raises(InstallException) as exc_info: - installer._extract_npm_package(str(tarball_path)) - - assert 'Zip bomb' in str(exc_info.value) - - @pytest.mark.integration - def test_path_traversal_protection_real_tarball(self, tmp_path): - """Test that extraction rejects tarballs with without package/ prefix.""" - import tarfile - import io - - # Create tarball with path traversal attempt - tarball_path = tmp_path / "malicious.tgz" - with create_test_tarball(tarball_path) as tar: - # Create a TarInfo with malicious path - info = tarfile.TarInfo(name="test") - info.size = 10 - tar.addfile(info, io.BytesIO(b"malicious!")) - - installer = install_dynamic_plugins.NpmPluginInstaller(str(tmp_path)) - - with pytest.raises(InstallException) as exc_info: - installer._extract_npm_package(str(tarball_path)) - - assert 'does not start with' in str(exc_info.value) - - @pytest.mark.integration - def test_symlink_with_invalid_linkpath_prefix(self, tmp_path): - """Test that extraction rejects symlinks with linkpath not starting with 'package/'.""" - import tarfile - import io - - # Create tarball with a symlink that has invalid linkpath prefix - tarball_path = tmp_path / "malicious.tgz" - with create_test_tarball(tarball_path) as tar: - # First add a regular file - info = tarfile.TarInfo(name="package/index.js") - info.size = 10 - tar.addfile(info, io.BytesIO(b"console.log")) - - # Add a symlink with linkpath not starting with 'package/' - link_info = tarfile.TarInfo(name="package/malicious-link") - link_info.type = tarfile.SYMTYPE - link_info.linkname = "../../../etc/passwd" # Does not start with 'package/' - tar.addfile(link_info) - - installer = install_dynamic_plugins.NpmPluginInstaller(str(tmp_path)) - - with pytest.raises(InstallException) as exc_info: - installer._extract_npm_package(str(tarball_path)) - - assert 'contains a link outside of the archive' in str(exc_info.value) - assert 'malicious-link' in str(exc_info.value) - - @pytest.mark.integration - def test_symlink_resolving_outside_directory(self, tmp_path): - """Test that extraction rejects symlinks that resolve outside the target directory.""" - import tarfile - import io - - # Create tarball with a symlink that resolves outside the extraction directory - tarball_path = tmp_path / "malicious.tgz" - with create_test_tarball(tarball_path) as tar: - # Add a regular file - info = tarfile.TarInfo(name="package/index.js") - info.size = 10 - tar.addfile(info, io.BytesIO(b"console.log")) - - # Add a symlink with proper prefix but resolves outside - # Using relative path traversal that starts with package/ but goes outside - link_info = tarfile.TarInfo(name="package/subdir/malicious-link") - link_info.type = tarfile.SYMTYPE - link_info.linkname = "package/../../../etc/passwd" # Starts with 'package/' but resolves outside - tar.addfile(link_info) - - installer = install_dynamic_plugins.NpmPluginInstaller(str(tmp_path)) - - with pytest.raises(InstallException) as exc_info: - installer._extract_npm_package(str(tarball_path)) - - assert 'contains a link outside of the archive' in str(exc_info.value) - - @pytest.mark.integration - def test_hardlink_resolving_outside_directory(self, tmp_path): - """Test that extraction rejects hardlinks that resolve outside the target directory.""" - import tarfile - import io - - # Create tarball with a hardlink that resolves outside the extraction directory - tarball_path = tmp_path / "malicious.tgz" - with create_test_tarball(tarball_path) as tar: - # Add a regular file - info = tarfile.TarInfo(name="package/index.js") - info.size = 10 - tar.addfile(info, io.BytesIO(b"console.log")) - - # Add a hardlink with proper prefix but resolves outside - link_info = tarfile.TarInfo(name="package/subdir/malicious-hardlink") - link_info.type = tarfile.LNKTYPE - link_info.linkname = "package/../../../etc/passwd" # Starts with 'package/' but resolves outside - tar.addfile(link_info) - - installer = install_dynamic_plugins.NpmPluginInstaller(str(tmp_path)) - - with pytest.raises(InstallException) as exc_info: - installer._extract_npm_package(str(tarball_path)) - - assert 'contains a link outside of the archive' in str(exc_info.value) - - @pytest.mark.integration - def test_valid_symlink_extraction(self, tmp_path): - """Test that valid symlinks within the package are extracted correctly.""" - import tarfile - import io - - # Create tarball with valid internal symlinks - tarball_path = tmp_path / "valid-package.tgz" - 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: () => {} };" - info.size = len(content) - tar.addfile(info, io.BytesIO(content)) - - # Add a valid symlink pointing to the file within package/ - link_info = tarfile.TarInfo(name="package/index.js") - link_info.type = tarfile.SYMTYPE - link_info.linkname = "package/lib/helper.js" - tar.addfile(link_info) - - installer = install_dynamic_plugins.NpmPluginInstaller(str(tmp_path)) - plugin_path = installer._extract_npm_package(str(tarball_path)) - - # Verify extraction succeeded - extracted_dir = tmp_path / plugin_path - assert extracted_dir.exists() - assert (extracted_dir / "lib" / "helper.js").exists() - assert (extracted_dir / "index.js").exists() - assert (extracted_dir / "index.js").is_symlink() - - @pytest.mark.integration - def test_install_real_npm_package(self, tmp_path): - """Integration test with actual npm pack on a real package.""" - import shutil - - # Only run if npm is available - if not shutil.which('npm'): - pytest.skip("npm not available") - - plugin = { - 'package': 'semver@7.0.0', # Small, stable package - 'integrity': 'sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==' - } - plugin_path_by_hash = {} - - installer = install_dynamic_plugins.NpmPluginInstaller(str(tmp_path), skip_integrity_check=False) - plugin_path = installer.install(plugin, plugin_path_by_hash) - - # Verify plugin was installed - installed_dir = tmp_path / plugin_path - assert installed_dir.exists() - assert (installed_dir / "package.json").exists() - -class TestOciDownloader: - """Test cases for OciDownloader class.""" - - def test_skopeo_command_execution(self, tmp_path, mocker): - """Test that skopeo commands are executed correctly.""" - # Mock shutil.which to return a fake skopeo path - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - - # Mock subprocess.run - mock_run = mocker.patch('subprocess.run') - mock_run.return_value.returncode = 0 - mock_run.return_value.stdout = b'output' - - downloader = install_dynamic_plugins.OciDownloader(str(tmp_path)) - result = downloader.skopeo(['inspect', 'docker://example.com/image:latest']) - - # Verify skopeo was called with correct arguments - mock_run.assert_called_once() - call_args = mock_run.call_args[0][0] - assert call_args[0] == '/usr/bin/skopeo' - assert call_args[1] == 'inspect' - assert result == b'output' - - def test_skopeo_not_found_raises_exception(self, tmp_path, mocker): - """Test that missing skopeo raises InstallException.""" - mocker.patch('shutil.which', return_value=None) - - with pytest.raises(InstallException) as exc_info: - install_dynamic_plugins.OciDownloader(str(tmp_path)) - - assert 'skopeo executable not found' in str(exc_info.value) - - def test_get_plugin_tar_caches_downloads(self, tmp_path, mocker): - """Test that get_plugin_tar caches downloaded images.""" - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - - # Mock skopeo copy - mock_run = mocker.patch('subprocess.run') - mock_run.return_value.returncode = 0 - - # Create fake manifest - manifest_data = { - 'layers': [{'digest': 'sha256:abc123'}] - } - - downloader = install_dynamic_plugins.OciDownloader(str(tmp_path)) - - # Mock the manifest file read - mocker.patch('builtins.open', mocker.mock_open(read_data=json.dumps(manifest_data))) - mocker.patch('os.path.join', side_effect=lambda *args: '/'.join(args)) - - image = 'oci://registry.io/plugin:v1.0' - - # First call should execute skopeo - tar_path1 = downloader.get_plugin_tar(image) - - # Second call should return cached result - tar_path2 = downloader.get_plugin_tar(image) - - # Should return same path - assert tar_path1 == tar_path2 - - # Verify image is cached - assert image in downloader.image_to_tarball - - def test_extract_plugin_with_valid_path(self, tmp_path, mocker): - """Test extracting a plugin from a tar file.""" - import tarfile - import io - - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - - # Create a real test tarball with plugin files - plugin_path = "internal-backstage-plugin-test" - tarball_path = tmp_path / "test.tar.gz" - - 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}") - content = b'{"name": "test"}' if filename.endswith('.json') else b'console.log("test");' - info.size = len(content) - tar.addfile(info, io.BytesIO(content)) - - downloader = install_dynamic_plugins.OciDownloader(str(tmp_path)) - downloader.extract_plugin(str(tarball_path), plugin_path) - - # Verify files were extracted - extracted_dir = tmp_path / plugin_path - assert extracted_dir.exists() - assert (extracted_dir / "package.json").exists() - assert (extracted_dir / "index.js").exists() - - def test_extract_plugin_rejects_oversized_files(self, tmp_path, mocker): - """Test that extract_plugin rejects files larger than max_entry_size.""" - import tarfile - import io - - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - - plugin_path = "plugin" - tarball_path = tmp_path / "malicious.tar.gz" - - # Create tarball with oversized file (needs actual content matching size) - large_content = b"x" * 25_000_000 # 25MB, exceeds default 20MB - - 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)) - - downloader = install_dynamic_plugins.OciDownloader(str(tmp_path)) - - with pytest.raises(InstallException) as exc_info: - downloader.extract_plugin(str(tarball_path), plugin_path) - - assert 'Zip bomb' in str(exc_info.value) - - def test_get_oci_plugin_paths_single_plugin(self, tmp_path, mocker): - """Test get_oci_plugin_paths with a single plugin in the image.""" - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - - # Mock skopeo inspect --raw to return manifest with single plugin - mock_run = mocker.patch('subprocess.run') - mock_run.return_value.returncode = 0 - - # Create test annotation data (raw manifest format) - plugins_metadata = [{ - "backstage-plugin-events-backend-module-github": { - "name": "@backstage/plugin-events-backend-module-github-dynamic", - "version": "0.4.3" - } - }] - annotation_value = base64.b64encode(json.dumps(plugins_metadata).encode('utf-8')).decode('utf-8') - - manifest_output = { - "schemaVersion": 2, - "annotations": { - "io.backstage.dynamic-packages": annotation_value - } - } - mock_run.return_value.stdout = json.dumps(manifest_output).encode('utf-8') - - paths = install_dynamic_plugins.get_oci_plugin_paths('oci://registry.io/plugin:v1.0') - - assert len(paths) == 1 - assert paths[0] == "backstage-plugin-events-backend-module-github" - - # Verify --raw flag was used - mock_run.assert_called() - call_args = mock_run.call_args[0][0] - assert '--raw' in call_args - - def test_get_oci_plugin_paths_multiple_plugins(self, tmp_path, mocker): - """Test get_oci_plugin_paths with multiple plugins in the image.""" - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - - mock_run = mocker.patch('subprocess.run') - mock_run.return_value.returncode = 0 - - # Create test annotation data with multiple plugins (raw manifest format) - plugins_metadata = [ - {"plugin-one": {"name": "@scope/plugin-one", "version": "1.0.0"}}, - {"plugin-two": {"name": "@scope/plugin-two", "version": "2.0.0"}} - ] - annotation_value = base64.b64encode(json.dumps(plugins_metadata).encode('utf-8')).decode('utf-8') - - manifest_output = { - "schemaVersion": 2, - "annotations": { - "io.backstage.dynamic-packages": annotation_value - } - } - mock_run.return_value.stdout = json.dumps(manifest_output).encode('utf-8') - - paths = install_dynamic_plugins.get_oci_plugin_paths('oci://registry.io/plugin:v1.0') - - assert len(paths) == 2 - assert "plugin-one" in paths - assert "plugin-two" in paths - - def test_get_oci_plugin_paths_no_annotation(self, tmp_path, mocker): - """Test get_oci_plugin_paths when annotation is missing.""" - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - - mock_run = mocker.patch('subprocess.run') - mock_run.return_value.returncode = 0 - - # Raw manifest without the plugin annotation - manifest_output = { - "schemaVersion": 2, - "annotations": {} - } - mock_run.return_value.stdout = json.dumps(manifest_output).encode('utf-8') - - paths = install_dynamic_plugins.get_oci_plugin_paths('oci://registry.io/plugin:v1.0') - - assert len(paths) == 0 - - @pytest.mark.integration - # Corresponds to the quay.io/rhdh/backstage-community-plugin-analytics-provider-segment:bcp-analytics-provider-segment-1-on-push-hv5kz-build-container image - # Not to quay.io/rhdh/backstage-community-plugin-analytics-provider-segment:1.10.0--1.22.2 which is a manifest list - @pytest.mark.parametrize("image", [ - 'oci://quay.io/rhdh/backstage-community-plugin-analytics-provider-segment@sha256:d465b0f4f85af8a0767a84055c366cebc11c8c1f6a8488248874e3acc7f148ee', - 'oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-analytics-provider-segment:bs_1.45.3__1.22.2' - ]) - def test_get_oci_plugin_paths_real_image(self, tmp_path, image): - """Test get_oci_plugin_paths with real OCI images.""" - import shutil - - # Skip if skopeo not available - if not shutil.which('skopeo'): - pytest.skip("skopeo not available") - - paths = install_dynamic_plugins.get_oci_plugin_paths(image) - - # Verify we got at least one plugin path - assert isinstance(paths, list) - assert len(paths) > 0 - - # Verify all paths are strings - for path in paths: - assert isinstance(path, str) - assert len(path) > 0 - # display path - print(f"\nPath: {path}") - - def test_download_with_explicit_path(self, tmp_path, mocker): - """Test download extracts the specified plugin path.""" - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - - downloader = install_dynamic_plugins.OciDownloader(str(tmp_path)) - - mocker.patch.object(downloader, 'get_plugin_tar', return_value='/fake/tar/path') - - def mock_extract(tar_file, plugin_path): - plugin_dir = tmp_path / plugin_path - plugin_dir.mkdir(parents=True, exist_ok=True) - (plugin_dir / "package.json").write_text('{"name": "test"}') - - mocker.patch.object(downloader, 'extract_plugin', side_effect=mock_extract) - - # download() always expects package with path (resolved by parse_plugin_key) - package = 'oci://registry.io/plugin:v1.0!explicit-plugin' - result = downloader.download(package) - - assert result == 'explicit-plugin' - downloader.extract_plugin.assert_called_once_with(tar_file='/fake/tar/path', plugin_path='explicit-plugin') - - def test_download_removes_previous_installation(self, tmp_path, mocker): - """Test that download removes previous plugin directory.""" - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - - # Create existing plugin directory with old content - plugin_path = "internal-backstage-plugin-test" - existing_dir = tmp_path / plugin_path - existing_dir.mkdir() - old_file = existing_dir / "old-file.txt" - old_file.write_text("old content") - old_subdir = existing_dir / "old-subdir" - old_subdir.mkdir() - (old_subdir / "old-nested.txt").write_text("old nested content") - - # Verify old content exists before - assert existing_dir.exists() - assert old_file.exists() - assert old_subdir.exists() - - # Mock get_plugin_tar and extract_plugin to simulate extraction - downloader = install_dynamic_plugins.OciDownloader(str(tmp_path)) - mocker.patch.object(downloader, 'get_plugin_tar', return_value='/fake/tar/path') - - def mock_extract(tar_file, plugin_path): - # Simulate extraction by creating new files - plugin_dir = tmp_path / plugin_path - plugin_dir.mkdir(parents=True, exist_ok=True) - (plugin_dir / "package.json").write_text('{"name": "new-plugin"}') - (plugin_dir / "index.js").write_text("console.log('new');") - - mocker.patch.object(downloader, 'extract_plugin', side_effect=mock_extract) - - package = f'oci://registry.io/plugin:v1.0!{plugin_path}' - result = downloader.download(package) - - # Verify extraction was called - downloader.extract_plugin.assert_called_once() - assert result == plugin_path - - # Verify old content was removed - assert not old_file.exists(), "Old file should have been removed" - assert not old_subdir.exists(), "Old subdirectory should have been removed" - - # Verify new content exists - new_dir = tmp_path / plugin_path - assert new_dir.exists(), "New plugin directory should exist" - assert (new_dir / "package.json").exists(), "New package.json should exist" - assert (new_dir / "index.js").exists(), "New index.js should exist" - - # Verify old content is definitely gone - assert not (new_dir / "old-file.txt").exists(), "Old file should not exist in new installation" - assert not (new_dir / "old-subdir").exists(), "Old subdirectory should not exist in new installation" - - def test_digest_returns_image_digest(self, tmp_path, mocker): - """Test that digest() returns the correct digest from remote image.""" - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - - # Mock skopeo inspect output - inspect_output = { - 'Digest': 'sha256:abc123def456789' - } - - mock_run = mocker.patch('subprocess.run') - mock_run.return_value.returncode = 0 - mock_run.return_value.stdout = json.dumps(inspect_output).encode('utf-8') - - downloader = install_dynamic_plugins.OciDownloader(str(tmp_path)) - package = 'oci://registry.io/plugin:v1.0!path' - - digest = downloader.digest(package) - - # Should return just the hash part - assert digest == 'abc123def456789' - - # Verify skopeo inspect was called - mock_run.assert_called_once() - call_args = mock_run.call_args[0][0] - assert 'inspect' in call_args - assert 'docker://registry.io/plugin:v1.0' in call_args - - -class TestOciPluginInstallerInstall: - """Test cases for OciPluginInstaller.install() method.""" - - def test_install_creates_digest_file(self, tmp_path, mocker): - """Test that install creates a digest file for tracking.""" - plugin_path = "test-plugin" - plugin = { - 'package': f'oci://registry.io/plugin:v1.0!{plugin_path}', - 'version': 'v1.0' - } - - # Mock the downloader - mock_downloader = mocker.MagicMock() - mock_downloader.download.return_value = plugin_path - mock_downloader.digest.return_value = 'abc123digest' - - installer = install_dynamic_plugins.OciPluginInstaller(str(tmp_path)) - installer.downloader = mock_downloader - - # Create the plugin directory that download would create - plugin_dir = tmp_path / plugin_path - plugin_dir.mkdir() - - result = installer.install(plugin, {}) - - # Verify digest file was created - digest_file = plugin_dir / 'dynamic-plugin-image.hash' - assert digest_file.exists() - assert digest_file.read_text() == 'abc123digest' - assert result == plugin_path - - def test_install_missing_version_raises_exception(self, tmp_path, mocker): - """Test that install raises exception when version is not set.""" - plugin = { - 'package': 'oci://registry.io/plugin:v1.0!path', - 'version': None - } - - installer = install_dynamic_plugins.OciPluginInstaller(str(tmp_path)) - - with pytest.raises(InstallException) as exc_info: - installer.install(plugin, {}) - - assert 'Tag or Digest is not set' in str(exc_info.value) - - def test_install_cleans_up_duplicate_hashes(self, tmp_path, mocker): - """Test that install removes duplicate hash entries.""" - plugin_path = "test-plugin" - plugin = { - 'package': f'oci://registry.io/plugin:v1.0!{plugin_path}', - 'version': 'v1.0', - 'plugin_hash': 'newhash' - } - - plugin_path_by_hash = { - 'oldhash': plugin_path, - 'anotherhash': plugin_path - } - - # Mock the downloader - mock_downloader = mocker.MagicMock() - mock_downloader.download.return_value = plugin_path - mock_downloader.digest.return_value = 'digest123' - - installer = install_dynamic_plugins.OciPluginInstaller(str(tmp_path)) - installer.downloader = mock_downloader - - # Create plugin directory - plugin_dir = tmp_path / plugin_path - plugin_dir.mkdir() - - result = installer.install(plugin, plugin_path_by_hash) - - # Verify old hashes were removed - assert 'oldhash' not in plugin_path_by_hash - assert 'anotherhash' not in plugin_path_by_hash - assert result == plugin_path - - def test_install_handles_download_errors(self, tmp_path, mocker): - """Test that install properly handles download errors.""" - plugin = { - 'package': 'oci://registry.io/plugin:v1.0!path', - 'version': 'v1.0' - } - - # Mock downloader to raise an exception - mock_downloader = mocker.MagicMock() - mock_downloader.download.side_effect = Exception("Network error") - - installer = install_dynamic_plugins.OciPluginInstaller(str(tmp_path)) - installer.downloader = mock_downloader - - with pytest.raises(InstallException) as exc_info: - installer.install(plugin, {}) - - assert 'Error while installing OCI plugin' in str(exc_info.value) - assert 'Network error' in str(exc_info.value) - - -@pytest.mark.integration -class TestOciIntegration: - """Integration tests with real OCI images.""" - - @pytest.mark.integration - def test_download_real_oci_image(self, tmp_path): - """Test downloading and extracting a real OCI image.""" - import shutil - - # Skip if skopeo not available - if not shutil.which('skopeo'): - pytest.skip("skopeo not available") - - package = 'oci://quay.io/gashcrumb/example-root-http-middleware:latest!internal-backstage-plugin-simple-chat' - - downloader = install_dynamic_plugins.OciDownloader(str(tmp_path)) - plugin_path = downloader.download(package) - - # Verify plugin was extracted - plugin_dir = tmp_path / plugin_path - assert plugin_dir.exists() - assert (plugin_dir / "package.json").exists() - - # Verify we can read package.json - package_json = json.loads((plugin_dir / "package.json").read_text()) - assert 'name' in package_json - - @pytest.mark.integration - def test_get_digest_from_real_image(self, tmp_path): - """Test getting digest from a real OCI image.""" - import shutil - - if not shutil.which('skopeo'): - pytest.skip("skopeo not available") - - package = 'oci://quay.io/gashcrumb/example-root-http-middleware:latest!internal-backstage-plugin-simple-chat' - - downloader = install_dynamic_plugins.OciDownloader(str(tmp_path)) - digest = downloader.digest(package) - - # Digest should be a hex string - assert isinstance(digest, str) - assert len(digest) > 0 - - @pytest.mark.integration - def test_install_oci_plugin_creates_hash_file(self, tmp_path): - """Test full installation of OCI plugin with hash file creation.""" - import shutil - - if not shutil.which('skopeo'): - pytest.skip("skopeo not available") - - plugin_path_name = 'internal-backstage-plugin-simple-chat' - plugin = { - 'package': f'oci://quay.io/gashcrumb/example-root-http-middleware:latest!{plugin_path_name}', - 'version': 'latest' - } - - installer = install_dynamic_plugins.OciPluginInstaller(str(tmp_path)) - plugin_path = installer.install(plugin, {}) - - # Verify installation - plugin_dir = tmp_path / plugin_path - assert plugin_dir.exists() - assert (plugin_dir / "package.json").exists() - - # Verify digest hash file was created - hash_file = plugin_dir / 'dynamic-plugin-image.hash' - assert hash_file.exists() - digest = hash_file.read_text().strip() - assert len(digest) > 0 - - @pytest.mark.integration - def test_download_multiple_plugins_from_same_image(self, tmp_path): - """Test downloading multiple plugins from the same OCI image.""" - import shutil - - if not shutil.which('skopeo'): - pytest.skip("skopeo not available") - - # Two plugins from the same image - packages = [ - 'oci://quay.io/gashcrumb/example-root-http-middleware:latest!internal-backstage-plugin-simple-chat', - 'oci://quay.io/gashcrumb/example-root-http-middleware:latest!internal-backstage-plugin-middleware-header-example-dynamic' - ] - - downloader = install_dynamic_plugins.OciDownloader(str(tmp_path)) - - plugin_paths = [] - for package in packages: - plugin_path = downloader.download(package) - plugin_paths.append(plugin_path) - - # Verify plugin was extracted - plugin_dir = tmp_path / plugin_path - assert plugin_dir.exists() - assert (plugin_dir / "package.json").exists() - - # Verify both plugins were extracted - assert len(plugin_paths) == 2 - assert plugin_paths[0] != plugin_paths[1] - - @pytest.mark.integration - def test_oci_plugin_with_inherit_version(self, tmp_path): - """Test that inherit version pattern works in plugin merge.""" - # This tests the version inheritance at the merge level - all_plugins = {} - - # First add a plugin with explicit version - plugin1 = { - 'package': 'oci://quay.io/gashcrumb/example-root-http-middleware:latest!internal-backstage-plugin-simple-chat-backend-dynamic' - } - merger1 = install_dynamic_plugins.OciPackageMerger(plugin1, 'test.yaml', all_plugins) - merger1.merge_plugin(level=0) - - # Then override with {{inherit}} - plugin2 = { - 'package': 'oci://quay.io/gashcrumb/example-root-http-middleware:{{inherit}}!internal-backstage-plugin-simple-chat-backend-dynamic', - 'disabled': False - } - merger2 = install_dynamic_plugins.OciPackageMerger(plugin2, 'test.yaml', all_plugins) - merger2.merge_plugin(level=1) - - # Version should be inherited from plugin1 - plugin_key = 'oci://quay.io/gashcrumb/example-root-http-middleware:!internal-backstage-plugin-simple-chat-backend-dynamic' - assert plugin_key in all_plugins - assert all_plugins[plugin_key]['version'] == 'latest' - assert all_plugins[plugin_key]['disabled'] is False - - -class TestGetLocalPackageInfo: - """Test cases for get_local_package_info() function.""" - - def test_package_with_valid_package_json(self, tmp_path): - """Test getting info from a package with valid package.json.""" - # Create a package directory with package.json - package_dir = tmp_path / "test-package" - package_dir.mkdir() - - package_json = { - "name": "test-package", - "version": "1.0.0", - "description": "A test package" - } - package_json_path = package_dir / "package.json" - package_json_path.write_text(json.dumps(package_json)) - - # Get package info - info = install_dynamic_plugins.get_local_package_info(str(package_dir)) - - # Verify the info contains expected fields - assert '_package_json' in info - assert info['_package_json'] == package_json - assert '_package_json_mtime' in info - assert info['_package_json_mtime'] == package_json_path.stat().st_mtime - # Should not have lock file mtimes - assert '_package-lock.json_mtime' not in info - assert '_yarn.lock_mtime' not in info - - def test_package_with_relative_path(self, tmp_path, monkeypatch): - """Test getting info from a package using relative path (./).""" - # Create a package directory - package_dir = tmp_path / "test-package" - package_dir.mkdir() - - package_json = {"name": "test-package", "version": "2.0.0"} - (package_dir / "package.json").write_text(json.dumps(package_json)) - - # Change to tmp_path directory and use relative path - monkeypatch.chdir(tmp_path) - - # Get package info with relative path - info = install_dynamic_plugins.get_local_package_info('./test-package') - - # Verify the info is correct - assert info['_package_json'] == package_json - assert '_package_json_mtime' in info - - def test_package_with_package_lock_json(self, tmp_path): - """Test getting info from a package with package-lock.json.""" - package_dir = tmp_path / "test-package" - package_dir.mkdir() - - package_json_path = package_dir / "package.json" - package_json_path.write_text(json.dumps({"name": "test", "version": "1.0.0"})) - - package_lock_path = package_dir / "package-lock.json" - package_lock_path.write_text(json.dumps({"lockfileVersion": 2})) - - # Get package info - info = install_dynamic_plugins.get_local_package_info(str(package_dir)) - - # Verify lock file mtime is included - assert '_package-lock.json_mtime' in info - assert info['_package-lock.json_mtime'] == package_lock_path.stat().st_mtime - assert '_yarn.lock_mtime' not in info - - def test_package_with_yarn_lock(self, tmp_path): - """Test getting info from a package with yarn.lock.""" - package_dir = tmp_path / "test-package" - package_dir.mkdir() - - package_json_path = package_dir / "package.json" - package_json_path.write_text(json.dumps({"name": "test", "version": "1.0.0"})) - - yarn_lock_path = package_dir / "yarn.lock" - yarn_lock_path.write_text("# yarn lockfile v1") - - # Get package info - info = install_dynamic_plugins.get_local_package_info(str(package_dir)) - - # Verify lock file mtime is included - assert '_yarn.lock_mtime' in info - assert info['_yarn.lock_mtime'] == yarn_lock_path.stat().st_mtime - assert '_package-lock.json_mtime' not in info - - def test_package_with_both_lock_files(self, tmp_path): - """Test getting info from a package with both package-lock.json and yarn.lock.""" - package_dir = tmp_path / "test-package" - package_dir.mkdir() - - package_json_path = package_dir / "package.json" - package_json_path.write_text(json.dumps({"name": "test", "version": "1.0.0"})) - - package_lock_path = package_dir / "package-lock.json" - package_lock_path.write_text(json.dumps({"lockfileVersion": 2})) - - yarn_lock_path = package_dir / "yarn.lock" - yarn_lock_path.write_text("# yarn lockfile v1") - - # Get package info - info = install_dynamic_plugins.get_local_package_info(str(package_dir)) - - # Verify both lock file mtimes are included - assert '_package-lock.json_mtime' in info - assert '_yarn.lock_mtime' in info - assert info['_package-lock.json_mtime'] == package_lock_path.stat().st_mtime - assert info['_yarn.lock_mtime'] == yarn_lock_path.stat().st_mtime - - def test_directory_without_package_json(self, tmp_path): - """Test getting info from a directory without package.json (falls back to directory mtime).""" - package_dir = tmp_path / "empty-package" - package_dir.mkdir() - - # Get package info - info = install_dynamic_plugins.get_local_package_info(str(package_dir)) - - # Should return directory mtime - assert '_directory_mtime' in info - assert info['_directory_mtime'] == package_dir.stat().st_mtime - assert '_package_json' not in info - - def test_nonexistent_path(self, tmp_path): - """Test getting info from a non-existent path.""" - nonexistent_path = tmp_path / "does-not-exist" - - # Get package info - info = install_dynamic_plugins.get_local_package_info(str(nonexistent_path)) - - # Should return _not_found flag - assert '_not_found' in info - assert info['_not_found'] is True - - def test_invalid_json_in_package_json(self, tmp_path): - """Test getting info when package.json contains invalid JSON.""" - package_dir = tmp_path / "test-package" - package_dir.mkdir() - - # Write invalid JSON - package_json_path = package_dir / "package.json" - package_json_path.write_text("{ invalid json content }") - - # Get package info - info = install_dynamic_plugins.get_local_package_info(str(package_dir)) - - # Should return error information - assert '_error' in info - assert 'JSONDecodeError' in info['_error'] or 'Expecting' in info['_error'] - - def test_package_info_detects_changes(self, tmp_path): - """Test that package info changes when files are modified.""" - package_dir = tmp_path / "test-package" - package_dir.mkdir() - - # Create initial package.json - package_json_path = package_dir / "package.json" - package_json_v1 = {"name": "test", "version": "1.0.0"} - package_json_path.write_text(json.dumps(package_json_v1)) - - # Get initial info - info1 = install_dynamic_plugins.get_local_package_info(str(package_dir)) - initial_mtime = info1['_package_json_mtime'] - - # Wait a bit and modify the file - import time - time.sleep(0.01) - - package_json_v2 = {"name": "test", "version": "2.0.0"} - package_json_path.write_text(json.dumps(package_json_v2)) - - # Get updated info - info2 = install_dynamic_plugins.get_local_package_info(str(package_dir)) - - # Verify that content and mtime changed - assert info2['_package_json'] != info1['_package_json'] - assert info2['_package_json']['version'] == "2.0.0" - assert info2['_package_json_mtime'] > initial_mtime - - def test_lock_file_mtime_detection(self, tmp_path): - """Test that lock file changes are detected via mtime.""" - package_dir = tmp_path / "test-package" - package_dir.mkdir() - - package_json_path = package_dir / "package.json" - package_json_path.write_text(json.dumps({"name": "test", "version": "1.0.0"})) - - # Get info without lock file - info1 = install_dynamic_plugins.get_local_package_info(str(package_dir)) - assert '_package-lock.json_mtime' not in info1 - - # Add lock file - import time - time.sleep(0.01) - - package_lock_path = package_dir / "package-lock.json" - package_lock_path.write_text(json.dumps({"lockfileVersion": 2})) - - # Get info with lock file - info2 = install_dynamic_plugins.get_local_package_info(str(package_dir)) - assert '_package-lock.json_mtime' in info2 - - # Hashes should be different due to lock file addition - hash1 = hashlib.sha256(json.dumps(info1, sort_keys=True).encode('utf-8')).hexdigest() - 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 and catalog entities - 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 catalog entities directory structure (using marketplace for backward compatibility) - catalog_entities_dir = layer_content_dir / "catalog-entities" / "marketplace" - catalog_entities_dir.mkdir(parents=True) - entity_file = catalog_entities_dir / "test-entity.yaml" - entity_file.write_text("apiVersion: backstage.io/v1alpha1\nkind: Component\nmetadata:\n name: test") - - # 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") - # Add catalog entities directory structure recursively - # This ensures the directory structure is preserved in the tarball - tar.add(str(layer_content_dir / "catalog-entities"), arcname="catalog-entities", recursive=True) - - return { - "oci_dir": str(oci_dir), - "manifest_path": str(manifest_path), - "layer_tarball": str(layer_tarball), - "yaml_content": yaml_content, - "entity_file": str(entity_file) - } - - @pytest.fixture - def mock_oci_image_with_extensions(self, tmp_path): - """Create a mock OCI image structure with extensions directory (new format).""" - import tarfile - - # Create a temporary directory for the OCI image - oci_dir = tmp_path / "oci-image-extensions" - 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:test456", - "size": 100 - }, - "layers": [ - { - "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", - "digest": "sha256:def789ghi012", - "size": 1000 - } - ] - } - manifest_path = oci_dir / "manifest.json" - manifest_path.write_text(json.dumps(manifest)) - - # Create a layer tarball with dynamic-plugins.default.yaml and catalog entities - layer_content_dir = tmp_path / "layer-content-extensions" - 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 catalog entities directory structure using extensions (new format) - catalog_entities_dir = layer_content_dir / "catalog-entities" / "extensions" - catalog_entities_dir.mkdir(parents=True) - entity_file = catalog_entities_dir / "test-entity.yaml" - entity_file.write_text("apiVersion: backstage.io/v1alpha1\nkind: Component\nmetadata:\n name: test-extensions") - - # Create the layer tarball - layer_tarball = oci_dir / "def789ghi012" - with create_test_tarball(layer_tarball) as tar: - tar.add(str(yaml_file), arcname="dynamic-plugins.default.yaml") - # Add catalog entities directory structure recursively - tar.add(str(layer_content_dir / "catalog-entities"), arcname="catalog-entities", recursive=True) - - return { - "oci_dir": str(oci_dir), - "manifest_path": str(manifest_path), - "layer_tarball": str(layer_tarball), - "yaml_content": yaml_content, - "entity_file": str(entity_file) - } - - 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), - str(tmp_path / "m4rk3tpl4c3") - ) - - def test_extract_catalog_index_skopeo_copy_fails(self, tmp_path, mocker): - """Test that function raises InstallException when skopeo copy fails.""" - import subprocess - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - - # Mock subprocess.run to raise CalledProcessError (since run_command uses check=True) - mock_error = subprocess.CalledProcessError( - returncode=1, - cmd=['/usr/bin/skopeo', 'copy', 'docker://quay.io/test/image:latest', 'dir:/tmp/...'] - ) - mock_error.stderr = "Error: image not found" - mock_error.stdout = "" - mocker.patch('subprocess.run', side_effect=mock_error) - - with pytest.raises(install_dynamic_plugins.InstallException) as exc_info: - install_dynamic_plugins.extract_catalog_index( - "quay.io/test/image:latest", - str(tmp_path), - str(tmp_path / "m4rk3tpl4c3") - ) - - # Verify the error message includes the expected content - error_msg = str(exc_info.value) - assert "Failed to download catalog index image" in error_msg - assert "command failed with exit code 1" in error_msg - assert "stderr: Error: image not found" in error_msg - - 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), - str(tmp_path / "m4rk3tpl4c3") - ) - - def test_extract_catalog_index_success(self, tmp_path, mocker, mock_oci_image, capsys): - """Test successful extraction of catalog index with dynamic-plugins.default.yaml.""" - catalog_mount = tmp_path / "catalog-mount" - catalog_mount.mkdir() - catalog_entities_parent_dir = tmp_path / "m4rk3tpl4c3" - - 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), - str(catalog_entities_parent_dir) - ) - - # 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 - - # Verify catalog entities were extracted - # Note: copytree copies the contents of marketplace into catalog-entities - entities_dir = catalog_entities_parent_dir / "catalog-entities" - assert entities_dir.exists() - entity_file = entities_dir / "test-entity.yaml" - assert entity_file.exists() - assert "kind: Component" in entity_file.read_text() - - # Verify success messages were printed - captured = capsys.readouterr() - assert 'Successfully extracted dynamic-plugins.default.yaml' in captured.out - assert 'Successfully extracted extensions catalog entities' in captured.out - - 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), - str(tmp_path / "m4rk3tpl4c3") - ) - - 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), - str(tmp_path / "m4rk3tpl4c3") - ) - - # 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), - str(tmp_path / "m4rk3tpl4c3") - ) - - def test_extract_catalog_index_extracts_catalog_entities(self, tmp_path, mocker, mock_oci_image, capsys): - """Test that catalog entities are extracted to the specified directory.""" - catalog_mount = tmp_path / "catalog-mount" - catalog_mount.mkdir() - catalog_entities_parent_dir = tmp_path / "entities-dest" - - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - - 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) - - install_dynamic_plugins.extract_catalog_index( - "quay.io/test/catalog-index:1.9", - str(catalog_mount), - str(catalog_entities_parent_dir) - ) - - # Verify catalog entities directory was created - # Note: copytree copies the contents of marketplace into catalog-entities - entities_dir = catalog_entities_parent_dir / "catalog-entities" - assert entities_dir.exists(), "Catalog entities directory should exist" - - # Verify entity file was copied - entity_file = entities_dir / "test-entity.yaml" - assert entity_file.exists(), "Entity file should be copied" - assert "kind: Component" in entity_file.read_text() - - # Verify success message was printed - captured = capsys.readouterr() - assert 'Successfully extracted extensions catalog entities' in captured.out - - def test_extract_catalog_index_creates_entities_directory(self, tmp_path, mocker, mock_oci_image): - """Test that catalog entities parent directory is created if it doesn't exist.""" - catalog_mount = tmp_path / "catalog-mount" - catalog_mount.mkdir() - catalog_entities_parent_dir = tmp_path / "new-entities-dir" - # Don't create the directory - let the function create it - - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - - 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) - - install_dynamic_plugins.extract_catalog_index( - "quay.io/test/catalog-index:1.9", - str(catalog_mount), - str(catalog_entities_parent_dir) - ) - - # Verify directory was created - assert catalog_entities_parent_dir.exists(), "Catalog entities parent directory should be created" - # Note: copytree copies the contents of marketplace into catalog-entities - entities_dir = catalog_entities_parent_dir / "catalog-entities" - assert entities_dir.exists(), "Catalog entities directory should exist" - - # Verify entity file was copied - entity_file = entities_dir / "test-entity.yaml" - assert entity_file.exists(), "Entity file should be copied" - - def test_extract_catalog_index_removes_existing_destination(self, tmp_path, mocker, mock_oci_image): - """Test that existing catalog-entities directory is removed before copying.""" - catalog_mount = tmp_path / "catalog-mount" - catalog_mount.mkdir() - catalog_entities_parent_dir = tmp_path / "existing-dir" - catalog_entities_parent_dir.mkdir() - - # Create an existing catalog-entities directory with old content - existing_entities_dir = catalog_entities_parent_dir / "catalog-entities" - existing_entities_dir.mkdir() - old_file = existing_entities_dir / "old-file.yaml" - old_file.write_text("old content") - old_subdir = existing_entities_dir / "old-subdir" - old_subdir.mkdir() - (old_subdir / "old-nested.yaml").write_text("old nested content") - - # Verify old content exists - assert existing_entities_dir.exists() - assert old_file.exists() - assert old_subdir.exists() - - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - - 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) - - install_dynamic_plugins.extract_catalog_index( - "quay.io/test/catalog-index:1.9", - str(catalog_mount), - str(catalog_entities_parent_dir) - ) - - # Verify old content was removed - assert not old_file.exists(), "Old file should have been removed" - assert not old_subdir.exists(), "Old subdirectory should have been removed" - - # Verify new content exists - entities_dir = catalog_entities_parent_dir / "catalog-entities" - assert entities_dir.exists(), "Catalog entities directory should exist" - entity_file = entities_dir / "test-entity.yaml" - assert entity_file.exists(), "New entity file should exist" - assert "kind: Component" in entity_file.read_text() - - # Verify old content is definitely gone - assert not (entities_dir / "old-file.yaml").exists(), "Old file should not exist" - assert not (entities_dir / "old-subdir").exists(), "Old subdirectory should not exist" - - def test_extract_catalog_index_uses_extensions_directory(self, tmp_path, mocker, mock_oci_image_with_extensions, capsys): - """Test that extraction prefers extensions directory over marketplace.""" - catalog_mount = tmp_path / "catalog-mount" - catalog_mount.mkdir() - catalog_entities_parent_dir = tmp_path / "entities-extensions" - - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - - mock_result = mocker.Mock() - mock_result.returncode = 0 - mock_subprocess_run = create_mock_skopeo_copy( - mock_oci_image_with_extensions['manifest_path'], - mock_oci_image_with_extensions['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-extensions:1.9", - str(catalog_mount), - str(catalog_entities_parent_dir) - ) - - # Verify the function returned a path - assert result is not None - assert result.endswith('dynamic-plugins.default.yaml') - - # Verify catalog entities were extracted from extensions directory - entities_dir = catalog_entities_parent_dir / "catalog-entities" - assert entities_dir.exists() - entity_file = entities_dir / "test-entity.yaml" - assert entity_file.exists() - assert "kind: Component" in entity_file.read_text() - assert "test-extensions" in entity_file.read_text() - - # Verify success messages were printed - captured = capsys.readouterr() - assert 'Successfully extracted dynamic-plugins.default.yaml' in captured.out - assert 'Successfully extracted extensions catalog entities' in captured.out - - def test_extract_catalog_index_falls_back_to_marketplace(self, tmp_path, mocker, mock_oci_image, capsys): - """Test that extraction falls back to marketplace directory when extensions doesn't exist.""" - catalog_mount = tmp_path / "catalog-mount" - catalog_mount.mkdir() - catalog_entities_parent_dir = tmp_path / "entities-marketplace" - - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - - 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-marketplace:1.9", - str(catalog_mount), - str(catalog_entities_parent_dir) - ) - - # Verify the function returned a path - assert result is not None - assert result.endswith('dynamic-plugins.default.yaml') - - # Verify catalog entities were extracted from marketplace directory (fallback) - entities_dir = catalog_entities_parent_dir / "catalog-entities" - assert entities_dir.exists() - entity_file = entities_dir / "test-entity.yaml" - assert entity_file.exists() - assert "kind: Component" in entity_file.read_text() - - # Verify success messages were printed - captured = capsys.readouterr() - assert 'Successfully extracted dynamic-plugins.default.yaml' in captured.out - assert 'Successfully extracted extensions catalog entities' in captured.out - - def test_extract_catalog_index_without_catalog_entities(self, tmp_path, mocker, capsys): - """Test that extraction succeeds with warning if neither extensions nor marketplace directory exists.""" - import tarfile - - catalog_mount = tmp_path / "catalog-mount" - catalog_mount.mkdir() - catalog_entities_parent_dir = tmp_path / "m4rk3tpl4c3" - - # Create OCI structure without catalog-entities - oci_dir = tmp_path / "oci-no-entities" - oci_dir.mkdir() - - manifest = { - "schemaVersion": 2, - "layers": [ - { - "digest": "sha256:noentities123", - "size": 500 - } - ] - } - manifest_path = oci_dir / "manifest.json" - manifest_path.write_text(json.dumps(manifest)) - - # Create layer tarball with only YAML file (no catalog-entities) - layer_tarball = oci_dir / "noentities123" - layer_content_dir = tmp_path / "layer-content-no-entities" - layer_content_dir.mkdir() - yaml_file = layer_content_dir / "dynamic-plugins.default.yaml" - yaml_file.write_text("plugins: []") - - with create_test_tarball(layer_tarball) as tar: - tar.add(str(yaml_file), arcname="dynamic-plugins.default.yaml") - - 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) - - # Should succeed even without catalog-entities, but print a warning - result = install_dynamic_plugins.extract_catalog_index( - "quay.io/test/no-entities-index:latest", - str(catalog_mount), - str(catalog_entities_parent_dir) - ) - - # Verify YAML file extraction succeeded - assert result is not None - assert result.endswith('dynamic-plugins.default.yaml') - - # Verify warning was printed with both directory names - captured = capsys.readouterr() - assert 'WARNING' in captured.out - assert 'does not have neither' in captured.out - assert 'catalog-entities/extensions/' in captured.out - assert 'catalog-entities/marketplace/' in captured.out - - # Verify catalog entities directory was not created - entities_dir = catalog_entities_parent_dir / "catalog-entities" - assert not entities_dir.exists() - -class TestImageExistsInRegistry: - """Tests for image_exists_in_registry function.""" - - def test_image_exists_returns_true(self, mocker): - """Test that image_exists_in_registry returns True when image exists.""" - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - mock_result = mocker.Mock() - mock_result.returncode = 0 - mocker.patch('subprocess.run', return_value=mock_result) - - result = install_dynamic_plugins.image_exists_in_registry('docker://quay.io/test/image:latest') - assert result is True - - def test_image_not_exists_returns_false(self, mocker): - """Test that image_exists_in_registry returns False when image doesn't exist.""" - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - mocker.patch('subprocess.run', side_effect=install_dynamic_plugins.subprocess.CalledProcessError(1, 'skopeo')) - - result = install_dynamic_plugins.image_exists_in_registry('docker://quay.io/test/nonexistent:latest') - assert result is False - - def test_skopeo_not_found_raises_exception(self, mocker): - """Test that missing skopeo raises InstallException.""" - mocker.patch('shutil.which', return_value=None) - - with pytest.raises(InstallException, match='skopeo executable not found'): - install_dynamic_plugins.image_exists_in_registry('docker://quay.io/test/image:latest') - - -class TestResolveImageReference: - """Tests for resolve_image_reference function.""" - - def test_non_rhdh_image_unchanged(self, mocker): - """Test that non-RHDH images are returned unchanged.""" - # No mocking needed - should return immediately without checking - result = install_dynamic_plugins.resolve_image_reference('oci://quay.io/other/image:v1.0') - assert result == 'oci://quay.io/other/image:v1.0' - - def test_rhdh_image_exists_returns_original(self, mocker, capsys): - """Test that existing RHDH image returns original reference.""" - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - mock_result = mocker.Mock() - mock_result.returncode = 0 - mocker.patch('subprocess.run', return_value=mock_result) - - result = install_dynamic_plugins.resolve_image_reference('oci://registry.access.redhat.com/rhdh/plugin:v1.0') - assert result == 'oci://registry.access.redhat.com/rhdh/plugin:v1.0' - - captured = capsys.readouterr() - assert 'Image found in registry.access.redhat.com/rhdh/' in captured.out - - def test_rhdh_image_not_exists_falls_back_to_quay(self, mocker, capsys): - """Test that missing RHDH image falls back to quay.io/rhdh/.""" - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - mocker.patch('subprocess.run', side_effect=install_dynamic_plugins.subprocess.CalledProcessError(1, 'skopeo')) - - result = install_dynamic_plugins.resolve_image_reference('oci://registry.access.redhat.com/rhdh/plugin:v1.0') - assert result == 'oci://quay.io/rhdh/plugin:v1.0' - - captured = capsys.readouterr() - assert 'falling back to quay.io/rhdh/' in captured.out - assert 'Using fallback image: quay.io/rhdh/plugin:v1.0' in captured.out - - def test_rhdh_docker_protocol_falls_back_to_quay(self, mocker, capsys): - """Test fallback works with docker:// protocol prefix.""" - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - mocker.patch('subprocess.run', side_effect=install_dynamic_plugins.subprocess.CalledProcessError(1, 'skopeo')) - - result = install_dynamic_plugins.resolve_image_reference('docker://registry.access.redhat.com/rhdh/plugin:v1.0') - assert result == 'docker://quay.io/rhdh/plugin:v1.0' - - def test_rhdh_no_protocol_falls_back_to_quay(self, mocker, capsys): - """Test fallback works without protocol prefix.""" - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - mocker.patch('subprocess.run', side_effect=install_dynamic_plugins.subprocess.CalledProcessError(1, 'skopeo')) - - result = install_dynamic_plugins.resolve_image_reference('registry.access.redhat.com/rhdh/plugin:v1.0') - assert result == 'quay.io/rhdh/plugin:v1.0' - - def test_rhdh_with_digest_falls_back_to_quay(self, mocker, capsys): - """Test fallback works with image digest format.""" - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - mocker.patch('subprocess.run', side_effect=install_dynamic_plugins.subprocess.CalledProcessError(1, 'skopeo')) - - result = install_dynamic_plugins.resolve_image_reference('oci://registry.access.redhat.com/rhdh/plugin@sha256:abc123') - assert result == 'oci://quay.io/rhdh/plugin@sha256:abc123' - - def test_rhdh_with_path_falls_back_preserving_path(self, mocker, capsys): - """Test fallback preserves full path after rhdh/.""" - mocker.patch('shutil.which', return_value='/usr/bin/skopeo') - mocker.patch('subprocess.run', side_effect=install_dynamic_plugins.subprocess.CalledProcessError(1, 'skopeo')) - - result = install_dynamic_plugins.resolve_image_reference('oci://registry.access.redhat.com/rhdh/catalog/plugin-name:v2.0') - assert result == 'oci://quay.io/rhdh/catalog/plugin-name:v2.0' - - -if __name__ == '__main__': - pytest.main([__file__, '-v']) - diff --git a/scripts/install-dynamic-plugins/tsconfig.build.json b/scripts/install-dynamic-plugins/tsconfig.build.json new file mode 100644 index 0000000000..9385ffaa2f --- /dev/null +++ b/scripts/install-dynamic-plugins/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": false, + "declarationMap": false + }, + "include": ["src/**/*.ts"] +} diff --git a/scripts/install-dynamic-plugins/tsconfig.json b/scripts/install-dynamic-plugins/tsconfig.json new file mode 100644 index 0000000000..29845ee835 --- /dev/null +++ b/scripts/install-dynamic-plugins/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "noEmit": true + }, + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] +} diff --git a/scripts/install-dynamic-plugins/vitest.config.ts b/scripts/install-dynamic-plugins/vitest.config.ts new file mode 100644 index 0000000000..0704da4c93 --- /dev/null +++ b/scripts/install-dynamic-plugins/vitest.config.ts @@ -0,0 +1,15 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const root = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + root, + test: { + environment: 'node', + include: ['test/**/*.test.ts'], + testTimeout: 120_000, + hookTimeout: 30_000 + } +}); diff --git a/scripts/install-dynamic-plugins/yarn.lock b/scripts/install-dynamic-plugins/yarn.lock new file mode 100644 index 0000000000..a0bc182b2a --- /dev/null +++ b/scripts/install-dynamic-plugins/yarn.lock @@ -0,0 +1,1871 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"@esbuild/aix-ppc64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/aix-ppc64@npm:0.27.2" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/aix-ppc64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/aix-ppc64@npm:0.27.4" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-arm64@npm:0.27.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/android-arm64@npm:0.27.4" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-arm@npm:0.27.2" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/android-arm@npm:0.27.4" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-x64@npm:0.27.2" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/android-x64@npm:0.27.4" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/darwin-arm64@npm:0.27.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/darwin-arm64@npm:0.27.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/darwin-x64@npm:0.27.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/darwin-x64@npm:0.27.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/freebsd-arm64@npm:0.27.2" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/freebsd-arm64@npm:0.27.4" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/freebsd-x64@npm:0.27.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/freebsd-x64@npm:0.27.4" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-arm64@npm:0.27.2" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-arm64@npm:0.27.4" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-arm@npm:0.27.2" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-arm@npm:0.27.4" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-ia32@npm:0.27.2" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-ia32@npm:0.27.4" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-loong64@npm:0.27.2" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-loong64@npm:0.27.4" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-mips64el@npm:0.27.2" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-mips64el@npm:0.27.4" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-ppc64@npm:0.27.2" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-ppc64@npm:0.27.4" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-riscv64@npm:0.27.2" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-riscv64@npm:0.27.4" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-s390x@npm:0.27.2" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-s390x@npm:0.27.4" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-x64@npm:0.27.2" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-x64@npm:0.27.4" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/netbsd-arm64@npm:0.27.2" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/netbsd-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/netbsd-arm64@npm:0.27.4" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/netbsd-x64@npm:0.27.2" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/netbsd-x64@npm:0.27.4" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openbsd-arm64@npm:0.27.2" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openbsd-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/openbsd-arm64@npm:0.27.4" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openbsd-x64@npm:0.27.2" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/openbsd-x64@npm:0.27.4" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openharmony-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openharmony-arm64@npm:0.27.2" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openharmony-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/openharmony-arm64@npm:0.27.4" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/sunos-x64@npm:0.27.2" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/sunos-x64@npm:0.27.4" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-arm64@npm:0.27.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/win32-arm64@npm:0.27.4" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-ia32@npm:0.27.2" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/win32-ia32@npm:0.27.4" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-x64@npm:0.27.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/win32-x64@npm:0.27.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@gar/promise-retry@npm:^1.0.0": + version: 1.0.3 + resolution: "@gar/promise-retry@npm:1.0.3" + checksum: 10c0/885b02c8b0d75b2d215da25f3b639158c4fbe8fefe0d79163304534b9a6d0710db4b7699f7cd3cc1a730792bff04cbe19f4850a62d3e105a663eaeec88f38332 + languageName: node + linkType: hard + +"@internal/install-dynamic-plugins@workspace:.": + version: 0.0.0-use.local + resolution: "@internal/install-dynamic-plugins@workspace:." + dependencies: + "@types/node": "npm:22.19.3" + esbuild: "npm:0.27.2" + proper-lockfile: "npm:4.1.2" + typescript: "npm:5.9.3" + vitest: "npm:3.2.4" + yaml: "npm:2.8.2" + languageName: unknown + linkType: soft + +"@isaacs/fs-minipass@npm:^4.0.0": + version: 4.0.1 + resolution: "@isaacs/fs-minipass@npm:4.0.1" + dependencies: + minipass: "npm:^7.0.4" + checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2 + languageName: node + linkType: hard + +"@jridgewell/sourcemap-codec@npm:^1.5.5": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 + languageName: node + linkType: hard + +"@npmcli/agent@npm:^4.0.0": + version: 4.0.0 + resolution: "@npmcli/agent@npm:4.0.0" + dependencies: + agent-base: "npm:^7.1.0" + http-proxy-agent: "npm:^7.0.0" + https-proxy-agent: "npm:^7.0.1" + lru-cache: "npm:^11.2.1" + socks-proxy-agent: "npm:^8.0.3" + checksum: 10c0/f7b5ce0f3dd42c3f8c6546e8433573d8049f67ef11ec22aa4704bc41483122f68bf97752e06302c455ead667af5cb753e6a09bff06632bc465c1cfd4c4b75a53 + languageName: node + linkType: hard + +"@npmcli/fs@npm:^5.0.0": + version: 5.0.0 + resolution: "@npmcli/fs@npm:5.0.0" + dependencies: + semver: "npm:^7.3.5" + checksum: 10c0/26e376d780f60ff16e874a0ac9bc3399186846baae0b6e1352286385ac134d900cc5dafaded77f38d77f86898fc923ae1cee9d7399f0275b1aa24878915d722b + languageName: node + linkType: hard + +"@npmcli/redact@npm:^4.0.0": + version: 4.0.0 + resolution: "@npmcli/redact@npm:4.0.0" + checksum: 10c0/a1e9ba9c70a6b40e175bda2c3dd8cfdaf096e6b7f7a132c855c083c8dfe545c3237cd56702e2e6627a580b1d63373599d49a1192c4078a85bf47bbde824df31c + languageName: node + linkType: hard + +"@rollup/rollup-android-arm-eabi@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.60.1" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@rollup/rollup-android-arm64@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-android-arm64@npm:4.60.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-arm64@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-darwin-arm64@npm:4.60.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-x64@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-darwin-x64@npm:4.60.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-arm64@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.60.1" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-x64@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-freebsd-x64@npm:4.60.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-gnueabihf@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.60.1" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-musleabihf@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.60.1" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-gnu@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.60.1" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-musl@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.60.1" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-loong64-gnu@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.60.1" + conditions: os=linux & cpu=loong64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-loong64-musl@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-loong64-musl@npm:4.60.1" + conditions: os=linux & cpu=loong64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-gnu@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.60.1" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-musl@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.60.1" + conditions: os=linux & cpu=ppc64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-gnu@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.60.1" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-musl@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.60.1" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-s390x-gnu@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.60.1" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-x64-gnu@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.60.1" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-x64-musl@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.60.1" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-openbsd-x64@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-openbsd-x64@npm:4.60.1" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-openharmony-arm64@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-openharmony-arm64@npm:4.60.1" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-arm64-msvc@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.60.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-ia32-msvc@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.60.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-gnu@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-win32-x64-gnu@npm:4.60.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-msvc@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.60.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@types/chai@npm:^5.2.2": + version: 5.2.3 + resolution: "@types/chai@npm:5.2.3" + dependencies: + "@types/deep-eql": "npm:*" + assertion-error: "npm:^2.0.1" + checksum: 10c0/e0ef1de3b6f8045a5e473e867c8565788c444271409d155588504840ad1a53611011f85072188c2833941189400228c1745d78323dac13fcede9c2b28bacfb2f + languageName: node + linkType: hard + +"@types/deep-eql@npm:*": + version: 4.0.2 + resolution: "@types/deep-eql@npm:4.0.2" + checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844 + languageName: node + linkType: hard + +"@types/estree@npm:1.0.8, @types/estree@npm:^1.0.0": + version: 1.0.8 + resolution: "@types/estree@npm:1.0.8" + checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5 + languageName: node + linkType: hard + +"@types/node@npm:22.19.3": + version: 22.19.3 + resolution: "@types/node@npm:22.19.3" + dependencies: + undici-types: "npm:~6.21.0" + checksum: 10c0/a30a75d503da795ddbd5f8851014f3e91925c2a63fa3f14128d54c998f25d682dfba96dc9589c73cf478b87a16d88beb790b11697bb8cd5bee913079237a58f2 + languageName: node + linkType: hard + +"@vitest/expect@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/expect@npm:3.2.4" + dependencies: + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:3.2.4" + "@vitest/utils": "npm:3.2.4" + chai: "npm:^5.2.0" + tinyrainbow: "npm:^2.0.0" + checksum: 10c0/7586104e3fd31dbe1e6ecaafb9a70131e4197dce2940f727b6a84131eee3decac7b10f9c7c72fa5edbdb68b6f854353bd4c0fa84779e274207fb7379563b10db + languageName: node + linkType: hard + +"@vitest/mocker@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/mocker@npm:3.2.4" + dependencies: + "@vitest/spy": "npm:3.2.4" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.17" + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/f7a4aea19bbbf8f15905847ee9143b6298b2c110f8b64789224cb0ffdc2e96f9802876aa2ca83f1ec1b6e1ff45e822abb34f0054c24d57b29ab18add06536ccd + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:3.2.4, @vitest/pretty-format@npm:^3.2.4": + version: 3.2.4 + resolution: "@vitest/pretty-format@npm:3.2.4" + dependencies: + tinyrainbow: "npm:^2.0.0" + checksum: 10c0/5ad7d4278e067390d7d633e307fee8103958806a419ca380aec0e33fae71b44a64415f7a9b4bc11635d3c13d4a9186111c581d3cef9c65cc317e68f077456887 + languageName: node + linkType: hard + +"@vitest/runner@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/runner@npm:3.2.4" + dependencies: + "@vitest/utils": "npm:3.2.4" + pathe: "npm:^2.0.3" + strip-literal: "npm:^3.0.0" + checksum: 10c0/e8be51666c72b3668ae3ea348b0196656a4a5adb836cb5e270720885d9517421815b0d6c98bfdf1795ed02b994b7bfb2b21566ee356a40021f5bf4f6ed4e418a + languageName: node + linkType: hard + +"@vitest/snapshot@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/snapshot@npm:3.2.4" + dependencies: + "@vitest/pretty-format": "npm:3.2.4" + magic-string: "npm:^0.30.17" + pathe: "npm:^2.0.3" + checksum: 10c0/f8301a3d7d1559fd3d59ed51176dd52e1ed5c2d23aa6d8d6aa18787ef46e295056bc726a021698d8454c16ed825ecba163362f42fa90258bb4a98cfd2c9424fc + languageName: node + linkType: hard + +"@vitest/spy@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/spy@npm:3.2.4" + dependencies: + tinyspy: "npm:^4.0.3" + checksum: 10c0/6ebf0b4697dc238476d6b6a60c76ba9eb1dd8167a307e30f08f64149612fd50227682b876420e4c2e09a76334e73f72e3ebf0e350714dc22474258292e202024 + languageName: node + linkType: hard + +"@vitest/utils@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/utils@npm:3.2.4" + dependencies: + "@vitest/pretty-format": "npm:3.2.4" + loupe: "npm:^3.1.4" + tinyrainbow: "npm:^2.0.0" + checksum: 10c0/024a9b8c8bcc12cf40183c246c244b52ecff861c6deb3477cbf487ac8781ad44c68a9c5fd69f8c1361878e55b97c10d99d511f2597f1f7244b5e5101d028ba64 + languageName: node + linkType: hard + +"abbrev@npm:^4.0.0": + version: 4.0.0 + resolution: "abbrev@npm:4.0.0" + checksum: 10c0/b4cc16935235e80702fc90192e349e32f8ef0ed151ef506aa78c81a7c455ec18375c4125414b99f84b2e055199d66383e787675f0bcd87da7a4dbd59f9eac1d5 + languageName: node + linkType: hard + +"agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": + version: 7.1.4 + resolution: "agent-base@npm:7.1.4" + checksum: 10c0/c2c9ab7599692d594b6a161559ada307b7a624fa4c7b03e3afdb5a5e31cd0e53269115b620fcab024c5ac6a6f37fa5eb2e004f076ad30f5f7e6b8b671f7b35fe + languageName: node + linkType: hard + +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 + languageName: node + linkType: hard + +"balanced-match@npm:^4.0.2": + version: 4.0.4 + resolution: "balanced-match@npm:4.0.4" + checksum: 10c0/07e86102a3eb2ee2a6a1a89164f29d0dbaebd28f2ca3f5ca786f36b8b23d9e417eb3be45a4acf754f837be5ac0a2317de90d3fcb7f4f4dc95720a1f36b26a17b + languageName: node + linkType: hard + +"brace-expansion@npm:^5.0.5": + version: 5.0.5 + resolution: "brace-expansion@npm:5.0.5" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10c0/4d238e14ed4f5cc9c07285550a41cef23121ca08ba99fa9eb5b55b580dcb6bf868b8210aa10526bdc9f8dc97f33ca2a7259039c4cc131a93042beddb424c48e3 + languageName: node + linkType: hard + +"cac@npm:^6.7.14": + version: 6.7.14 + resolution: "cac@npm:6.7.14" + checksum: 10c0/4ee06aaa7bab8981f0d54e5f5f9d4adcd64058e9697563ce336d8a3878ed018ee18ebe5359b2430eceae87e0758e62ea2019c3f52ae6e211b1bd2e133856cd10 + languageName: node + linkType: hard + +"cacache@npm:^20.0.1": + version: 20.0.4 + resolution: "cacache@npm:20.0.4" + dependencies: + "@npmcli/fs": "npm:^5.0.0" + fs-minipass: "npm:^3.0.0" + glob: "npm:^13.0.0" + lru-cache: "npm:^11.1.0" + minipass: "npm:^7.0.3" + minipass-collect: "npm:^2.0.1" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + p-map: "npm:^7.0.2" + ssri: "npm:^13.0.0" + checksum: 10c0/539bf4020e44ba9ca5afc2ec435623ed7e0dd80c020097677e6b4a0545df5cc9d20b473212d01209c8b4aea43c0d095af0bb6da97bcb991642ea6fac0d7c462b + languageName: node + linkType: hard + +"chai@npm:^5.2.0": + version: 5.3.3 + resolution: "chai@npm:5.3.3" + dependencies: + assertion-error: "npm:^2.0.1" + check-error: "npm:^2.1.1" + deep-eql: "npm:^5.0.1" + loupe: "npm:^3.1.0" + pathval: "npm:^2.0.0" + checksum: 10c0/b360fd4d38861622e5010c2f709736988b05c7f31042305fa3f4e9911f6adb80ccfb4e302068bf8ed10e835c2e2520cba0f5edc13d878b886987e5aa62483f53 + languageName: node + linkType: hard + +"check-error@npm:^2.1.1": + version: 2.1.3 + resolution: "check-error@npm:2.1.3" + checksum: 10c0/878e99038fb6476316b74668cd6a498c7e66df3efe48158fa40db80a06ba4258742ac3ee2229c4a2a98c5e73f5dff84eb3e50ceb6b65bbd8f831eafc8338607d + languageName: node + linkType: hard + +"chownr@npm:^3.0.0": + version: 3.0.0 + resolution: "chownr@npm:3.0.0" + checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10 + languageName: node + linkType: hard + +"debug@npm:4, debug@npm:^4.3.4, debug@npm:^4.4.1": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 + languageName: node + linkType: hard + +"deep-eql@npm:^5.0.1": + version: 5.0.2 + resolution: "deep-eql@npm:5.0.2" + checksum: 10c0/7102cf3b7bb719c6b9c0db2e19bf0aa9318d141581befe8c7ce8ccd39af9eaa4346e5e05adef7f9bd7015da0f13a3a25dcfe306ef79dc8668aedbecb658dd247 + languageName: node + linkType: hard + +"env-paths@npm:^2.2.0": + version: 2.2.1 + resolution: "env-paths@npm:2.2.1" + checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 + languageName: node + linkType: hard + +"es-module-lexer@npm:^1.7.0": + version: 1.7.0 + resolution: "es-module-lexer@npm:1.7.0" + checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b + languageName: node + linkType: hard + +"esbuild@npm:0.27.2": + version: 0.27.2 + resolution: "esbuild@npm:0.27.2" + dependencies: + "@esbuild/aix-ppc64": "npm:0.27.2" + "@esbuild/android-arm": "npm:0.27.2" + "@esbuild/android-arm64": "npm:0.27.2" + "@esbuild/android-x64": "npm:0.27.2" + "@esbuild/darwin-arm64": "npm:0.27.2" + "@esbuild/darwin-x64": "npm:0.27.2" + "@esbuild/freebsd-arm64": "npm:0.27.2" + "@esbuild/freebsd-x64": "npm:0.27.2" + "@esbuild/linux-arm": "npm:0.27.2" + "@esbuild/linux-arm64": "npm:0.27.2" + "@esbuild/linux-ia32": "npm:0.27.2" + "@esbuild/linux-loong64": "npm:0.27.2" + "@esbuild/linux-mips64el": "npm:0.27.2" + "@esbuild/linux-ppc64": "npm:0.27.2" + "@esbuild/linux-riscv64": "npm:0.27.2" + "@esbuild/linux-s390x": "npm:0.27.2" + "@esbuild/linux-x64": "npm:0.27.2" + "@esbuild/netbsd-arm64": "npm:0.27.2" + "@esbuild/netbsd-x64": "npm:0.27.2" + "@esbuild/openbsd-arm64": "npm:0.27.2" + "@esbuild/openbsd-x64": "npm:0.27.2" + "@esbuild/openharmony-arm64": "npm:0.27.2" + "@esbuild/sunos-x64": "npm:0.27.2" + "@esbuild/win32-arm64": "npm:0.27.2" + "@esbuild/win32-ia32": "npm:0.27.2" + "@esbuild/win32-x64": "npm:0.27.2" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/cf83f626f55500f521d5fe7f4bc5871bec240d3deb2a01fbd379edc43b3664d1167428738a5aad8794b35d1cca985c44c375b1cd38a2ca613c77ced2c83aafcd + languageName: node + linkType: hard + +"esbuild@npm:^0.27.0": + version: 0.27.4 + resolution: "esbuild@npm:0.27.4" + dependencies: + "@esbuild/aix-ppc64": "npm:0.27.4" + "@esbuild/android-arm": "npm:0.27.4" + "@esbuild/android-arm64": "npm:0.27.4" + "@esbuild/android-x64": "npm:0.27.4" + "@esbuild/darwin-arm64": "npm:0.27.4" + "@esbuild/darwin-x64": "npm:0.27.4" + "@esbuild/freebsd-arm64": "npm:0.27.4" + "@esbuild/freebsd-x64": "npm:0.27.4" + "@esbuild/linux-arm": "npm:0.27.4" + "@esbuild/linux-arm64": "npm:0.27.4" + "@esbuild/linux-ia32": "npm:0.27.4" + "@esbuild/linux-loong64": "npm:0.27.4" + "@esbuild/linux-mips64el": "npm:0.27.4" + "@esbuild/linux-ppc64": "npm:0.27.4" + "@esbuild/linux-riscv64": "npm:0.27.4" + "@esbuild/linux-s390x": "npm:0.27.4" + "@esbuild/linux-x64": "npm:0.27.4" + "@esbuild/netbsd-arm64": "npm:0.27.4" + "@esbuild/netbsd-x64": "npm:0.27.4" + "@esbuild/openbsd-arm64": "npm:0.27.4" + "@esbuild/openbsd-x64": "npm:0.27.4" + "@esbuild/openharmony-arm64": "npm:0.27.4" + "@esbuild/sunos-x64": "npm:0.27.4" + "@esbuild/win32-arm64": "npm:0.27.4" + "@esbuild/win32-ia32": "npm:0.27.4" + "@esbuild/win32-x64": "npm:0.27.4" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/2a1c2bcccda279f2afd72a7f8259860cb4483b32453d17878e1ecb4ac416b9e7c1001e7aa0a25ba4c29c1e250a3ceaae5d8bb72a119815bc8db4e9b5f5321490 + languageName: node + linkType: hard + +"estree-walker@npm:^3.0.3": + version: 3.0.3 + resolution: "estree-walker@npm:3.0.3" + dependencies: + "@types/estree": "npm:^1.0.0" + checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d + languageName: node + linkType: hard + +"expect-type@npm:^1.2.1": + version: 1.3.0 + resolution: "expect-type@npm:1.3.0" + checksum: 10c0/8412b3fe4f392c420ab41dae220b09700e4e47c639a29ba7ba2e83cc6cffd2b4926f7ac9e47d7e277e8f4f02acda76fd6931cb81fd2b382fa9477ef9ada953fd + languageName: node + linkType: hard + +"exponential-backoff@npm:^3.1.1": + version: 3.1.3 + resolution: "exponential-backoff@npm:3.1.3" + checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267 + languageName: node + linkType: hard + +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f + languageName: node + linkType: hard + +"fs-minipass@npm:^3.0.0": + version: 3.0.3 + resolution: "fs-minipass@npm:3.0.3" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10c0/63e80da2ff9b621e2cb1596abcb9207f1cf82b968b116ccd7b959e3323144cce7fb141462200971c38bbf2ecca51695069db45265705bed09a7cd93ae5b89f94 + languageName: node + linkType: hard + +"fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60 + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + +"glob@npm:^13.0.0": + version: 13.0.6 + resolution: "glob@npm:13.0.6" + dependencies: + minimatch: "npm:^10.2.2" + minipass: "npm:^7.1.3" + path-scurry: "npm:^2.0.2" + checksum: 10c0/269c236f11a9b50357fe7a8c6aadac667e01deb5242b19c84975628f05f4438d8ee1354bb62c5d6c10f37fd59911b54d7799730633a2786660d8c69f1d18120a + languageName: node + linkType: hard + +"graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 + languageName: node + linkType: hard + +"http-cache-semantics@npm:^4.1.1": + version: 4.2.0 + resolution: "http-cache-semantics@npm:4.2.0" + checksum: 10c0/45b66a945cf13ec2d1f29432277201313babf4a01d9e52f44b31ca923434083afeca03f18417f599c9ab3d0e7b618ceb21257542338b57c54b710463b4a53e37 + languageName: node + linkType: hard + +"http-proxy-agent@npm:^7.0.0": + version: 7.0.2 + resolution: "http-proxy-agent@npm:7.0.2" + dependencies: + agent-base: "npm:^7.1.0" + debug: "npm:^4.3.4" + checksum: 10c0/4207b06a4580fb85dd6dff521f0abf6db517489e70863dca1a0291daa7f2d3d2d6015a57bd702af068ea5cf9f1f6ff72314f5f5b4228d299c0904135d2aef921 + languageName: node + linkType: hard + +"https-proxy-agent@npm:^7.0.1": + version: 7.0.6 + resolution: "https-proxy-agent@npm:7.0.6" + dependencies: + agent-base: "npm:^7.1.2" + debug: "npm:4" + checksum: 10c0/f729219bc735edb621fa30e6e84e60ee5d00802b8247aac0d7b79b0bd6d4b3294737a337b93b86a0bd9e68099d031858a39260c976dc14cdbba238ba1f8779ac + languageName: node + linkType: hard + +"iconv-lite@npm:^0.7.2": + version: 0.7.2 + resolution: "iconv-lite@npm:0.7.2" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10c0/3c228920f3bd307f56bf8363706a776f4a060eb042f131cd23855ceca962951b264d0997ab38a1ad340e1c5df8499ed26e1f4f0db6b2a2ad9befaff22f14b722 + languageName: node + linkType: hard + +"ip-address@npm:^10.0.1": + version: 10.1.0 + resolution: "ip-address@npm:10.1.0" + checksum: 10c0/0103516cfa93f6433b3bd7333fa876eb21263912329bfa47010af5e16934eeeff86f3d2ae700a3744a137839ddfad62b900c7a445607884a49b5d1e32a3d7566 + languageName: node + linkType: hard + +"isexe@npm:^4.0.0": + version: 4.0.0 + resolution: "isexe@npm:4.0.0" + checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce + languageName: node + linkType: hard + +"js-tokens@npm:^9.0.1": + version: 9.0.1 + resolution: "js-tokens@npm:9.0.1" + checksum: 10c0/68dcab8f233dde211a6b5fd98079783cbcd04b53617c1250e3553ee16ab3e6134f5e65478e41d82f6d351a052a63d71024553933808570f04dbf828d7921e80e + languageName: node + linkType: hard + +"loupe@npm:^3.1.0, loupe@npm:^3.1.4": + version: 3.2.1 + resolution: "loupe@npm:3.2.1" + checksum: 10c0/910c872cba291309664c2d094368d31a68907b6f5913e989d301b5c25f30e97d76d77f23ab3bf3b46d0f601ff0b6af8810c10c31b91d2c6b2f132809ca2cc705 + languageName: node + linkType: hard + +"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1": + version: 11.2.7 + resolution: "lru-cache@npm:11.2.7" + checksum: 10c0/549cdb59488baa617135fc12159cafb1a97f91079f35093bb3bcad72e849fc64ace636d244212c181dfdf1a99bbfa90757ff303f98561958ee4d0f885d9bd5f7 + languageName: node + linkType: hard + +"magic-string@npm:^0.30.17": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a + languageName: node + linkType: hard + +"make-fetch-happen@npm:^15.0.0": + version: 15.0.5 + resolution: "make-fetch-happen@npm:15.0.5" + dependencies: + "@gar/promise-retry": "npm:^1.0.0" + "@npmcli/agent": "npm:^4.0.0" + "@npmcli/redact": "npm:^4.0.0" + cacache: "npm:^20.0.1" + http-cache-semantics: "npm:^4.1.1" + minipass: "npm:^7.0.2" + minipass-fetch: "npm:^5.0.0" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + negotiator: "npm:^1.0.0" + proc-log: "npm:^6.0.0" + ssri: "npm:^13.0.0" + checksum: 10c0/527580eb5e5476e6ad07a4e3bd017d13e935f4be815674b442081ae5a721c13d3af5715006619e6be79a85723067e047f83a0c9e699f41d8cec43609a8de4f7b + languageName: node + linkType: hard + +"minimatch@npm:^10.2.2": + version: 10.2.5 + resolution: "minimatch@npm:10.2.5" + dependencies: + brace-expansion: "npm:^5.0.5" + checksum: 10c0/6bb058bd6324104b9ec2f763476a35386d05079c1f5fe4fbf1f324a25237cd4534d6813ecd71f48208f4e635c1221899bef94c3c89f7df55698fe373aaae20fd + languageName: node + linkType: hard + +"minipass-collect@npm:^2.0.1": + version: 2.0.1 + resolution: "minipass-collect@npm:2.0.1" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10c0/5167e73f62bb74cc5019594709c77e6a742051a647fe9499abf03c71dca75515b7959d67a764bdc4f8b361cf897fbf25e2d9869ee039203ed45240f48b9aa06e + languageName: node + linkType: hard + +"minipass-fetch@npm:^5.0.0": + version: 5.0.2 + resolution: "minipass-fetch@npm:5.0.2" + dependencies: + iconv-lite: "npm:^0.7.2" + minipass: "npm:^7.0.3" + minipass-sized: "npm:^2.0.0" + minizlib: "npm:^3.0.1" + dependenciesMeta: + iconv-lite: + optional: true + checksum: 10c0/ce4ab9f21cfabaead2097d95dd33f485af8072fbc6b19611bce694965393453a1639d641c2bcf1c48f2ea7d41ea7fab8278373f1d0bee4e63b0a5b2cdd0ef649 + languageName: node + linkType: hard + +"minipass-flush@npm:^1.0.5": + version: 1.0.7 + resolution: "minipass-flush@npm:1.0.7" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/960915c02aa0991662c37c404517dd93708d17f96533b2ca8c1e776d158715d8107c5ced425ffc61674c167d93607f07f48a83c139ce1057f8781e5dfb4b90c2 + languageName: node + linkType: hard + +"minipass-pipeline@npm:^1.2.4": + version: 1.2.4 + resolution: "minipass-pipeline@npm:1.2.4" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/cbda57cea20b140b797505dc2cac71581a70b3247b84480c1fed5ca5ba46c25ecc25f68bfc9e6dcb1a6e9017dab5c7ada5eab73ad4f0a49d84e35093e0c643f2 + languageName: node + linkType: hard + +"minipass-sized@npm:^2.0.0": + version: 2.0.0 + resolution: "minipass-sized@npm:2.0.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10c0/f9201696a6f6d68610d04c9c83e3d2e5cb9c026aae1c8cbf7e17f386105cb79c1bb088dbc21bf0b1eb4f3fb5df384fd1e7aa3bf1f33868c416ae8c8a92679db8 + languageName: node + linkType: hard + +"minipass@npm:^3.0.0": + version: 3.3.6 + resolution: "minipass@npm:3.3.6" + dependencies: + yallist: "npm:^4.0.0" + checksum: 10c0/a114746943afa1dbbca8249e706d1d38b85ed1298b530f5808ce51f8e9e941962e2a5ad2e00eae7dd21d8a4aae6586a66d4216d1a259385e9d0358f0c1eba16c + languageName: node + linkType: hard + +"minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2, minipass@npm:^7.1.3": + version: 7.1.3 + resolution: "minipass@npm:7.1.3" + checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb + languageName: node + linkType: hard + +"minizlib@npm:^3.0.1, minizlib@npm:^3.1.0": + version: 3.1.0 + resolution: "minizlib@npm:3.1.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec + languageName: node + linkType: hard + +"ms@npm:^2.1.3": + version: 2.1.3 + resolution: "ms@npm:2.1.3" + checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 + languageName: node + linkType: hard + +"nanoid@npm:^3.3.11": + version: 3.3.11 + resolution: "nanoid@npm:3.3.11" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/40e7f70b3d15f725ca072dfc4f74e81fcf1fbb02e491cf58ac0c79093adc9b0a73b152bcde57df4b79cd097e13023d7504acb38404a4da7bc1cd8e887b82fe0b + languageName: node + linkType: hard + +"negotiator@npm:^1.0.0": + version: 1.0.0 + resolution: "negotiator@npm:1.0.0" + checksum: 10c0/4c559dd52669ea48e1914f9d634227c561221dd54734070791f999c52ed0ff36e437b2e07d5c1f6e32909fc625fe46491c16e4a8f0572567d4dd15c3a4fda04b + languageName: node + linkType: hard + +"node-gyp@npm:latest": + version: 12.2.0 + resolution: "node-gyp@npm:12.2.0" + dependencies: + env-paths: "npm:^2.2.0" + exponential-backoff: "npm:^3.1.1" + graceful-fs: "npm:^4.2.6" + make-fetch-happen: "npm:^15.0.0" + nopt: "npm:^9.0.0" + proc-log: "npm:^6.0.0" + semver: "npm:^7.3.5" + tar: "npm:^7.5.4" + tinyglobby: "npm:^0.2.12" + which: "npm:^6.0.0" + bin: + node-gyp: bin/node-gyp.js + checksum: 10c0/3ed046746a5a7d90950cd8b0547332b06598443f31fe213ef4332a7174c7b7d259e1704835feda79b87d3f02e59d7791842aac60642ede4396ab25fdf0f8f759 + languageName: node + linkType: hard + +"nopt@npm:^9.0.0": + version: 9.0.0 + resolution: "nopt@npm:9.0.0" + dependencies: + abbrev: "npm:^4.0.0" + bin: + nopt: bin/nopt.js + checksum: 10c0/1822eb6f9b020ef6f7a7516d7b64a8036e09666ea55ac40416c36e4b2b343122c3cff0e2f085675f53de1d2db99a2a89a60ccea1d120bcd6a5347bf6ceb4a7fd + languageName: node + linkType: hard + +"p-map@npm:^7.0.2": + version: 7.0.4 + resolution: "p-map@npm:7.0.4" + checksum: 10c0/a5030935d3cb2919d7e89454d1ce82141e6f9955413658b8c9403cfe379283770ed3048146b44cde168aa9e8c716505f196d5689db0ae3ce9a71521a2fef3abd + languageName: node + linkType: hard + +"path-scurry@npm:^2.0.2": + version: 2.0.2 + resolution: "path-scurry@npm:2.0.2" + dependencies: + lru-cache: "npm:^11.0.0" + minipass: "npm:^7.1.2" + checksum: 10c0/b35ad37cf6557a87fd057121ce2be7695380c9138d93e87ae928609da259ea0a170fac6f3ef1eb3ece8a068e8b7f2f3adf5bb2374cf4d4a57fe484954fcc9482 + languageName: node + linkType: hard + +"pathe@npm:^2.0.3": + version: 2.0.3 + resolution: "pathe@npm:2.0.3" + checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1 + languageName: node + linkType: hard + +"pathval@npm:^2.0.0": + version: 2.0.1 + resolution: "pathval@npm:2.0.1" + checksum: 10c0/460f4709479fbf2c45903a65655fc8f0a5f6d808f989173aeef5fdea4ff4f303dc13f7870303999add60ec49d4c14733895c0a869392e9866f1091fa64fd7581 + languageName: node + linkType: hard + +"picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + +"picomatch@npm:^4.0.2, picomatch@npm:^4.0.3": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 + languageName: node + linkType: hard + +"postcss@npm:^8.5.6": + version: 8.5.8 + resolution: "postcss@npm:8.5.8" + dependencies: + nanoid: "npm:^3.3.11" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/dd918f7127ee7c60a0295bae2e72b3787892296e1d1c3c564d7a2a00c68d8df83cadc3178491259daa19ccc54804fb71ed8c937c6787e08d8bd4bedf8d17044c + languageName: node + linkType: hard + +"proc-log@npm:^6.0.0": + version: 6.1.0 + resolution: "proc-log@npm:6.1.0" + checksum: 10c0/4f178d4062733ead9d71a9b1ab24ebcecdfe2250916a5b1555f04fe2eda972a0ec76fbaa8df1ad9c02707add6749219d118a4fc46dc56bdfe4dde4b47d80bb82 + languageName: node + linkType: hard + +"proper-lockfile@npm:4.1.2": + version: 4.1.2 + resolution: "proper-lockfile@npm:4.1.2" + dependencies: + graceful-fs: "npm:^4.2.4" + retry: "npm:^0.12.0" + signal-exit: "npm:^3.0.2" + checksum: 10c0/2f265dbad15897a43110a02dae55105c04d356ec4ed560723dcb9f0d34bc4fb2f13f79bb930e7561be10278e2314db5aca2527d5d3dcbbdee5e6b331d1571f6d + languageName: node + linkType: hard + +"retry@npm:^0.12.0": + version: 0.12.0 + resolution: "retry@npm:0.12.0" + checksum: 10c0/59933e8501727ba13ad73ef4a04d5280b3717fd650408460c987392efe9d7be2040778ed8ebe933c5cbd63da3dcc37919c141ef8af0a54a6e4fca5a2af177bfe + languageName: node + linkType: hard + +"rollup@npm:^4.43.0": + version: 4.60.1 + resolution: "rollup@npm:4.60.1" + dependencies: + "@rollup/rollup-android-arm-eabi": "npm:4.60.1" + "@rollup/rollup-android-arm64": "npm:4.60.1" + "@rollup/rollup-darwin-arm64": "npm:4.60.1" + "@rollup/rollup-darwin-x64": "npm:4.60.1" + "@rollup/rollup-freebsd-arm64": "npm:4.60.1" + "@rollup/rollup-freebsd-x64": "npm:4.60.1" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.60.1" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.60.1" + "@rollup/rollup-linux-arm64-gnu": "npm:4.60.1" + "@rollup/rollup-linux-arm64-musl": "npm:4.60.1" + "@rollup/rollup-linux-loong64-gnu": "npm:4.60.1" + "@rollup/rollup-linux-loong64-musl": "npm:4.60.1" + "@rollup/rollup-linux-ppc64-gnu": "npm:4.60.1" + "@rollup/rollup-linux-ppc64-musl": "npm:4.60.1" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.60.1" + "@rollup/rollup-linux-riscv64-musl": "npm:4.60.1" + "@rollup/rollup-linux-s390x-gnu": "npm:4.60.1" + "@rollup/rollup-linux-x64-gnu": "npm:4.60.1" + "@rollup/rollup-linux-x64-musl": "npm:4.60.1" + "@rollup/rollup-openbsd-x64": "npm:4.60.1" + "@rollup/rollup-openharmony-arm64": "npm:4.60.1" + "@rollup/rollup-win32-arm64-msvc": "npm:4.60.1" + "@rollup/rollup-win32-ia32-msvc": "npm:4.60.1" + "@rollup/rollup-win32-x64-gnu": "npm:4.60.1" + "@rollup/rollup-win32-x64-msvc": "npm:4.60.1" + "@types/estree": "npm:1.0.8" + fsevents: "npm:~2.3.2" + dependenciesMeta: + "@rollup/rollup-android-arm-eabi": + optional: true + "@rollup/rollup-android-arm64": + optional: true + "@rollup/rollup-darwin-arm64": + optional: true + "@rollup/rollup-darwin-x64": + optional: true + "@rollup/rollup-freebsd-arm64": + optional: true + "@rollup/rollup-freebsd-x64": + optional: true + "@rollup/rollup-linux-arm-gnueabihf": + optional: true + "@rollup/rollup-linux-arm-musleabihf": + optional: true + "@rollup/rollup-linux-arm64-gnu": + optional: true + "@rollup/rollup-linux-arm64-musl": + optional: true + "@rollup/rollup-linux-loong64-gnu": + optional: true + "@rollup/rollup-linux-loong64-musl": + optional: true + "@rollup/rollup-linux-ppc64-gnu": + optional: true + "@rollup/rollup-linux-ppc64-musl": + optional: true + "@rollup/rollup-linux-riscv64-gnu": + optional: true + "@rollup/rollup-linux-riscv64-musl": + optional: true + "@rollup/rollup-linux-s390x-gnu": + optional: true + "@rollup/rollup-linux-x64-gnu": + optional: true + "@rollup/rollup-linux-x64-musl": + optional: true + "@rollup/rollup-openbsd-x64": + optional: true + "@rollup/rollup-openharmony-arm64": + optional: true + "@rollup/rollup-win32-arm64-msvc": + optional: true + "@rollup/rollup-win32-ia32-msvc": + optional: true + "@rollup/rollup-win32-x64-gnu": + optional: true + "@rollup/rollup-win32-x64-msvc": + optional: true + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: 10c0/48d3f2216b5533639b007e6756e2275c7f594e45adee21ce03674aa2e004406c661f8b86c7a0b471c9e889c6a9efbb29240ca0b7673c50e391406c490c309833 + languageName: node + linkType: hard + +"safer-buffer@npm:>= 2.1.2 < 3.0.0": + version: 2.1.2 + resolution: "safer-buffer@npm:2.1.2" + checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4 + languageName: node + linkType: hard + +"semver@npm:^7.3.5": + version: 7.7.4 + resolution: "semver@npm:7.7.4" + bin: + semver: bin/semver.js + checksum: 10c0/5215ad0234e2845d4ea5bb9d836d42b03499546ddafb12075566899fc617f68794bb6f146076b6881d755de17d6c6cc73372555879ec7dce2c2feee947866ad2 + languageName: node + linkType: hard + +"siginfo@npm:^2.0.0": + version: 2.0.0 + resolution: "siginfo@npm:2.0.0" + checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34 + languageName: node + linkType: hard + +"signal-exit@npm:^3.0.2": + version: 3.0.7 + resolution: "signal-exit@npm:3.0.7" + checksum: 10c0/25d272fa73e146048565e08f3309d5b942c1979a6f4a58a8c59d5fa299728e9c2fcd1a759ec870863b1fd38653670240cd420dad2ad9330c71f36608a6a1c912 + languageName: node + linkType: hard + +"smart-buffer@npm:^4.2.0": + version: 4.2.0 + resolution: "smart-buffer@npm:4.2.0" + checksum: 10c0/a16775323e1404dd43fabafe7460be13a471e021637bc7889468eb45ce6a6b207261f454e4e530a19500cc962c4cc5348583520843b363f4193cee5c00e1e539 + languageName: node + linkType: hard + +"socks-proxy-agent@npm:^8.0.3": + version: 8.0.5 + resolution: "socks-proxy-agent@npm:8.0.5" + dependencies: + agent-base: "npm:^7.1.2" + debug: "npm:^4.3.4" + socks: "npm:^2.8.3" + checksum: 10c0/5d2c6cecba6821389aabf18728325730504bf9bb1d9e342e7987a5d13badd7a98838cc9a55b8ed3cb866ad37cc23e1086f09c4d72d93105ce9dfe76330e9d2a6 + languageName: node + linkType: hard + +"socks@npm:^2.8.3": + version: 2.8.7 + resolution: "socks@npm:2.8.7" + dependencies: + ip-address: "npm:^10.0.1" + smart-buffer: "npm:^4.2.0" + checksum: 10c0/2805a43a1c4bcf9ebf6e018268d87b32b32b06fbbc1f9282573583acc155860dc361500f89c73bfbb157caa1b4ac78059eac0ef15d1811eb0ca75e0bdadbc9d2 + languageName: node + linkType: hard + +"source-map-js@npm:^1.2.1": + version: 1.2.1 + resolution: "source-map-js@npm:1.2.1" + checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf + languageName: node + linkType: hard + +"ssri@npm:^13.0.0": + version: 13.0.1 + resolution: "ssri@npm:13.0.1" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10c0/cf6408a18676c57ff2ed06b8a20dc64bb3e748e5c7e095332e6aecaa2b8422b1e94a739a8453bf65156a8a47afe23757ba4ab52d3ea3b62322dc40875763e17a + languageName: node + linkType: hard + +"stackback@npm:0.0.2": + version: 0.0.2 + resolution: "stackback@npm:0.0.2" + checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983 + languageName: node + linkType: hard + +"std-env@npm:^3.9.0": + version: 3.10.0 + resolution: "std-env@npm:3.10.0" + checksum: 10c0/1814927a45004d36dde6707eaf17552a546769bc79a6421be2c16ce77d238158dfe5de30910b78ec30d95135cc1c59ea73ee22d2ca170f8b9753f84da34c427f + languageName: node + linkType: hard + +"strip-literal@npm:^3.0.0": + version: 3.1.0 + resolution: "strip-literal@npm:3.1.0" + dependencies: + js-tokens: "npm:^9.0.1" + checksum: 10c0/50918f669915d9ad0fe4b7599902b735f853f2201c97791ead00104a654259c0c61bc2bc8fa3db05109339b61f4cf09e47b94ecc874ffbd0e013965223893af8 + languageName: node + linkType: hard + +"tar@npm:^7.5.4": + version: 7.5.13 + resolution: "tar@npm:7.5.13" + dependencies: + "@isaacs/fs-minipass": "npm:^4.0.0" + chownr: "npm:^3.0.0" + minipass: "npm:^7.1.2" + minizlib: "npm:^3.1.0" + yallist: "npm:^5.0.0" + checksum: 10c0/5c65b8084799bde7a791593a1c1a45d3d6ee98182e3700b24c247b7b8f8654df4191642abbdb07ff25043d45dcff35620827c3997b88ae6c12040f64bed5076b + languageName: node + linkType: hard + +"tinybench@npm:^2.9.0": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c + languageName: node + linkType: hard + +"tinyexec@npm:^0.3.2": + version: 0.3.2 + resolution: "tinyexec@npm:0.3.2" + checksum: 10c0/3efbf791a911be0bf0821eab37a3445c2ba07acc1522b1fa84ae1e55f10425076f1290f680286345ed919549ad67527d07281f1c19d584df3b74326909eb1f90 + languageName: node + linkType: hard + +"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.15": + version: 0.2.15 + resolution: "tinyglobby@npm:0.2.15" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.3" + checksum: 10c0/869c31490d0d88eedb8305d178d4c75e7463e820df5a9b9d388291daf93e8b1eb5de1dad1c1e139767e4269fe75f3b10d5009b2cc14db96ff98986920a186844 + languageName: node + linkType: hard + +"tinypool@npm:^1.1.1": + version: 1.1.1 + resolution: "tinypool@npm:1.1.1" + checksum: 10c0/bf26727d01443061b04fa863f571016950888ea994ba0cd8cba3a1c51e2458d84574341ab8dbc3664f1c3ab20885c8cf9ff1cc4b18201f04c2cde7d317fff69b + languageName: node + linkType: hard + +"tinyrainbow@npm:^2.0.0": + version: 2.0.0 + resolution: "tinyrainbow@npm:2.0.0" + checksum: 10c0/c83c52bef4e0ae7fb8ec6a722f70b5b6fa8d8be1c85792e829f56c0e1be94ab70b293c032dc5048d4d37cfe678f1f5babb04bdc65fd123098800148ca989184f + languageName: node + linkType: hard + +"tinyspy@npm:^4.0.3": + version: 4.0.4 + resolution: "tinyspy@npm:4.0.4" + checksum: 10c0/a8020fc17799251e06a8398dcc352601d2770aa91c556b9531ecd7a12581161fd1c14e81cbdaff0c1306c93bfdde8ff6d1c1a3f9bbe6d91604f0fd4e01e2f1eb + languageName: node + linkType: hard + +"typescript@npm:5.9.3": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A5.9.3#optional!builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 + languageName: node + linkType: hard + +"undici-types@npm:~6.21.0": + version: 6.21.0 + resolution: "undici-types@npm:6.21.0" + checksum: 10c0/c01ed51829b10aa72fc3ce64b747f8e74ae9b60eafa19a7b46ef624403508a54c526ffab06a14a26b3120d055e1104d7abe7c9017e83ced038ea5cf52f8d5e04 + languageName: node + linkType: hard + +"vite-node@npm:3.2.4": + version: 3.2.4 + resolution: "vite-node@npm:3.2.4" + dependencies: + cac: "npm:^6.7.14" + debug: "npm:^4.4.1" + es-module-lexer: "npm:^1.7.0" + pathe: "npm:^2.0.3" + vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" + bin: + vite-node: vite-node.mjs + checksum: 10c0/6ceca67c002f8ef6397d58b9539f80f2b5d79e103a18367288b3f00a8ab55affa3d711d86d9112fce5a7fa658a212a087a005a045eb8f4758947dd99af2a6c6b + languageName: node + linkType: hard + +"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0": + version: 7.3.1 + resolution: "vite@npm:7.3.1" + dependencies: + esbuild: "npm:^0.27.0" + fdir: "npm:^6.5.0" + fsevents: "npm:~2.3.3" + picomatch: "npm:^4.0.3" + postcss: "npm:^8.5.6" + rollup: "npm:^4.43.0" + tinyglobby: "npm:^0.2.15" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + jiti: ">=1.21.0" + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/5c7548f5f43a23533e53324304db4ad85f1896b1bfd3ee32ae9b866bac2933782c77b350eb2b52a02c625c8ad1ddd4c000df077419410650c982cd97fde8d014 + languageName: node + linkType: hard + +"vitest@npm:3.2.4": + version: 3.2.4 + resolution: "vitest@npm:3.2.4" + dependencies: + "@types/chai": "npm:^5.2.2" + "@vitest/expect": "npm:3.2.4" + "@vitest/mocker": "npm:3.2.4" + "@vitest/pretty-format": "npm:^3.2.4" + "@vitest/runner": "npm:3.2.4" + "@vitest/snapshot": "npm:3.2.4" + "@vitest/spy": "npm:3.2.4" + "@vitest/utils": "npm:3.2.4" + chai: "npm:^5.2.0" + debug: "npm:^4.4.1" + expect-type: "npm:^1.2.1" + magic-string: "npm:^0.30.17" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.2" + std-env: "npm:^3.9.0" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^0.3.2" + tinyglobby: "npm:^0.2.14" + tinypool: "npm:^1.1.1" + tinyrainbow: "npm:^2.0.0" + vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" + vite-node: "npm:3.2.4" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@types/debug": ^4.1.12 + "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 + "@vitest/browser": 3.2.4 + "@vitest/ui": 3.2.4 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/debug": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10c0/5bf53ede3ae6a0e08956d72dab279ae90503f6b5a05298a6a5e6ef47d2fd1ab386aaf48fafa61ed07a0ebfe9e371772f1ccbe5c258dd765206a8218bf2eb79eb + languageName: node + linkType: hard + +"which@npm:^6.0.0": + version: 6.0.1 + resolution: "which@npm:6.0.1" + dependencies: + isexe: "npm:^4.0.0" + bin: + node-which: bin/which.js + checksum: 10c0/7e710e54ea36d2d6183bee2f9caa27a3b47b9baf8dee55a199b736fcf85eab3b9df7556fca3d02b50af7f3dfba5ea3a45644189836df06267df457e354da66d5 + languageName: node + linkType: hard + +"why-is-node-running@npm:^2.3.0": + version: 2.3.0 + resolution: "why-is-node-running@npm:2.3.0" + dependencies: + siginfo: "npm:^2.0.0" + stackback: "npm:0.0.2" + bin: + why-is-node-running: cli.js + checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054 + languageName: node + linkType: hard + +"yallist@npm:^4.0.0": + version: 4.0.0 + resolution: "yallist@npm:4.0.0" + checksum: 10c0/2286b5e8dbfe22204ab66e2ef5cc9bbb1e55dfc873bbe0d568aa943eb255d131890dfd5bf243637273d31119b870f49c18fcde2c6ffbb7a7a092b870dc90625a + languageName: node + linkType: hard + +"yallist@npm:^5.0.0": + version: 5.0.0 + resolution: "yallist@npm:5.0.0" + checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416 + languageName: node + linkType: hard + +"yaml@npm:2.8.2": + version: 2.8.2 + resolution: "yaml@npm:2.8.2" + bin: + yaml: bin.mjs + checksum: 10c0/703e4dc1e34b324aa66876d63618dcacb9ed49f7e7fe9b70f1e703645be8d640f68ab84f12b86df8ac960bac37acf5513e115de7c970940617ce0343c8c9cd96 + languageName: node + linkType: hard