diff --git a/iib/web/api_v1.py b/iib/web/api_v1.py index 76a7d3e60..7720f1552 100644 --- a/iib/web/api_v1.py +++ b/iib/web/api_v1.py @@ -56,10 +56,10 @@ handle_recursive_related_bundles_request, ) from iib.workers.tasks.build_regenerate_bundle import handle_regenerate_bundle_request -from iib.workers.tasks.build_merge_index_image import handle_merge_request from iib.workers.tasks.build_containerized_create_empty_index import ( handle_containerized_create_empty_index_request, ) +from iib.workers.tasks.build_containerized_merge import handle_containerized_merge_request from iib.workers.tasks.general import failed_request_callback from iib.web.iib_static_types import ( AddDeprecationRequestPayload, @@ -1133,7 +1133,7 @@ def merge_index_image() -> Tuple[flask.Response, int]: error_callback = failed_request_callback.s(request.id) try: - handle_merge_request.apply_async( + handle_containerized_merge_request.apply_async( args=args, link_error=error_callback, argsrepr=repr(safe_args), queue=celery_queue ) except kombu.exceptions.OperationalError: diff --git a/iib/workers/config.py b/iib/workers/config.py index ef5f7f965..0b12a5085 100644 --- a/iib/workers/config.py +++ b/iib/workers/config.py @@ -97,6 +97,7 @@ class Config(object): 'iib.workers.tasks.build_containerized_fbc_operations', 'iib.workers.tasks.build_containerized_rm', 'iib.workers.tasks.build_containerized_create_empty_index', + 'iib.workers.tasks.build_containerized_merge', 'iib.workers.tasks.general', ] # Path to hidden location of SQLite database diff --git a/iib/workers/tasks/build_containerized_merge.py b/iib/workers/tasks/build_containerized_merge.py new file mode 100644 index 000000000..f9a675d68 --- /dev/null +++ b/iib/workers/tasks/build_containerized_merge.py @@ -0,0 +1,377 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +import logging +import os +import tempfile +import shutil +from typing import Dict, List, Optional + + +from iib.common.common_utils import get_binary_versions +from iib.common.tracing import instrument_tracing +from iib.exceptions import IIBError +from iib.workers.api_utils import set_request_state +from iib.workers.tasks.build import ( + _update_index_image_build_state, + _get_present_bundles, + _update_index_image_pull_spec, +) +from iib.workers.tasks.celery import app +from iib.workers.tasks.containerized_utils import ( + write_build_metadata, + cleanup_on_failure, + cleanup_merge_request_if_exists, + push_index_db_artifact, + validate_bundles_in_parallel, + fetch_and_verify_index_db_artifact, + prepare_git_repository_for_build, + git_commit_and_create_mr_or_push, + monitor_pipeline_and_extract_image, + replicate_image_to_tagged_destinations, +) +from iib.workers.tasks.build_merge_index_image import get_missing_bundles_from_target_to_source +from iib.workers.tasks.build_merge_index_image import get_bundles_latest_version +from iib.workers.tasks.opm_operations import ( + Opm, + _opm_registry_add, + deprecate_bundles_db, + opm_migrate, + opm_validate, + get_list_bundles, +) +from iib.workers.tasks.utils import ( + prepare_request_for_build, + request_logger, + reset_docker_config, + RequestConfigMerge, + set_registry_token, + get_bundles_from_deprecation_list, +) +from iib.workers.tasks.fbc_utils import merge_catalogs_dirs +from iib.workers.tasks.iib_static_types import BundleImage + + +__all__ = ['handle_containerized_merge_request'] + +log = logging.getLogger(__name__) + + +@app.task +@request_logger +@instrument_tracing( + span_name="workers.tasks.build.handle_containerized_merge_request", + attributes=get_binary_versions(), +) +def handle_containerized_merge_request( + source_from_index: str, + deprecation_list: List[str], + request_id: int, + binary_image: Optional[str] = None, + target_index: Optional[str] = None, + overwrite_target_index: bool = False, + overwrite_target_index_token: Optional[str] = None, + distribution_scope: Optional[str] = None, + binary_image_config: Optional[str] = None, + build_tags: Optional[List[str]] = None, + graph_update_mode: Optional[str] = None, + ignore_bundle_ocp_version: Optional[bool] = False, + index_to_gitlab_push_map: Optional[Dict[str, str]] = None, + parallel_threads: int = 5, +) -> None: + """ + Coordinate the work needed to merge old (N) index image with new (N+1) index image. + + :param str source_from_index: pull specification to be used as the base for building the new + index image. + :param str target_index: pull specification of content stage index image for the + corresponding target index image. + :param list deprecation_list: list of deprecated bundles for the target index image. + :param int request_id: the ID of the IIB build request. + :param str binary_image: the pull specification of the container image where the opm binary + gets copied from. + :param bool overwrite_target_index: if True, overwrite the input ``target_index`` with + the built index image. + :param str overwrite_target_index_token: the token used for overwriting the input + ``target_index`` image. This is required to use ``overwrite_target_index``. + The format of the token must be in the format "user:password". + :param str distribution_scope: the scope for distribution of the index image, defaults to + ``None``. + :param build_tags: list of extra tag to use for intermediate index image + :param str graph_update_mode: Graph update mode that defines how channel graphs are updated + in the index. + :param bool ignore_bundle_ocp_version: When set to `true` and image set as target_index is + listed in `iib_no_ocp_label_allow_list` config then bundles without + "com.redhat.openshift.versions" label set will be added in the result `index_image`. + :raises IIBError: if the index image merge fails. + :param dict index_to_gitlab_push_map: the dict mapping index images (keys) to GitLab repos + (values) in order to push their catalogs into GitLab. + :param int parallel_threads: the number of parallel threads to use for validating the bundles + :raises IIBError: if the index image merge fails. + """ + reset_docker_config() + set_request_state(request_id, 'in_progress', 'Preparing request for merge') + + # Prepare request + with set_registry_token(overwrite_target_index_token, target_index, append=True): + prebuild_info = prepare_request_for_build( + request_id, + RequestConfigMerge( + _binary_image=binary_image, + overwrite_target_index_token=overwrite_target_index_token, + source_from_index=source_from_index, + target_index=target_index, + distribution_scope=distribution_scope, + binary_image_config=binary_image_config, + ), + ) + + source_from_index_resolved = prebuild_info['source_from_index_resolved'] + target_index_resolved = prebuild_info['target_index_resolved'] + + # Set OPM version + Opm.set_opm_version(target_index_resolved) + opm_version = Opm.opm_version + + _update_index_image_build_state(request_id, prebuild_info) + + mr_details: Optional[Dict[str, str]] = None + local_git_repo_path: Optional[str] = None + index_git_repo: Optional[str] = None + last_commit_sha: Optional[str] = None + output_pull_spec: Optional[str] = None + original_index_db_digest: Optional[str] = None + + with tempfile.TemporaryDirectory(prefix=f'iib-{request_id}-') as temp_dir: + # Setup and clone Git repository + branch = prebuild_info['ocp_version'] + ( + index_git_repo, + local_git_repo_path, + localized_git_catalog_path, + ) = prepare_git_repository_for_build( + request_id=request_id, + from_index=source_from_index, + temp_dir=temp_dir, + branch=branch, + index_to_gitlab_push_map=index_to_gitlab_push_map or {}, + ) + + # Pull both source and target index.db artifacts and read present bundle + target_index_db_path = None + source_index_db_path = fetch_and_verify_index_db_artifact(source_from_index, temp_dir) + if target_index: + target_index_db_path = fetch_and_verify_index_db_artifact(target_index, temp_dir) + + # Get the bundles from the index.db file + with set_registry_token(overwrite_target_index_token, target_index, append=True): + target_index_bundles: List[BundleImage] = [] + target_index_bundles_pull_spec: List[str] = [] + + source_index_bundles, source_index_bundles_pull_spec = _get_present_bundles( + source_index_db_path, temp_dir + ) + log.debug("Source index bundles %s", source_index_bundles) + log.debug("Source index bundles pull spec %s", source_index_bundles_pull_spec) + + if target_index_db_path: + target_index_bundles, target_index_bundles_pull_spec = _get_present_bundles( + target_index_db_path, temp_dir + ) + log.debug("Target index bundles %s", target_index_bundles) + log.debug("Target index bundles pull spec %s", target_index_bundles_pull_spec) + + # Validate the bundles from source and target have their pullspecs present in the registry + set_request_state( + request_id, + 'in_progress', + 'Validating whether the bundles have their pullspecs present in the registry', + ) + unique_bundles = set(source_index_bundles_pull_spec + target_index_bundles_pull_spec) + validate_bundles_in_parallel( + bundles=list(unique_bundles), + threads=parallel_threads, + wait=True, + ) + + set_request_state(request_id, 'in_progress', 'Adding bundles missing in source index image') + log.info('Adding bundles from target index image which are missing from source index image') + + missing_bundles, invalid_bundles = get_missing_bundles_from_target_to_source( + source_index_bundles=source_index_bundles, + target_index_bundles=target_index_bundles, + source_from_index=source_from_index_resolved, + ocp_version=prebuild_info['target_ocp_version'], + target_index=target_index_resolved, + ignore_bundle_ocp_version=ignore_bundle_ocp_version, + ) + missing_bundle_paths = [bundle['bundlePath'] for bundle in missing_bundles] + + # Add the missing bundles to the index.db file + set_request_state( + request_id, 'in_progress', 'Adding the missing bundles to the source index.db file' + ) + + if target_index_db_path: + _opm_registry_add(temp_dir, source_index_db_path, missing_bundle_paths) + + # Process the deprecation list + set_request_state(request_id, 'in_progress', 'Processing the deprecation list') + intermediate_bundles = missing_bundle_paths + source_index_bundles_pull_spec + deprecation_bundles = get_bundles_from_deprecation_list( + intermediate_bundles, deprecation_list + ) + deprecation_bundles = deprecation_bundles + [ + bundle['bundlePath'] for bundle in invalid_bundles + ] + + # process the deprecation list into the intermediary index.db file + if deprecation_bundles: + # We need to get the latest pullpecs from bundles in order to avoid failures + # on "opm deprecatetruncate" due to versions already removed before. + # Once we give the latest versions all lower ones get automatically deprecated by OPM. + all_bundles = source_index_bundles + target_index_bundles + deprecation_bundles = get_bundles_latest_version(deprecation_bundles, all_bundles) + + deprecate_bundles_db( + base_dir=temp_dir, index_db_file=source_index_db_path, bundles=deprecation_bundles + ) + + # Retrieve the operators from the intermediary index.db file + # This will be required for pushing the updated index.db file to the IIB registry + bundles_in_db = get_list_bundles(source_index_db_path, temp_dir) + operators_in_db = [bundle['packageName'] for bundle in bundles_in_db] + + # Migrate the intermediary index.db file to FBC and generate the Dockerfile + set_request_state( + request_id, + 'in_progress', + 'Migrating the intermediary index.db file to FBC and generating the Dockerfile', + ) + fbc_dir, _ = opm_migrate(source_index_db_path, temp_dir) + + # rename `catalog` directory because we need to use this name for + # final destination of catalog (defined in Dockerfile) + catalog_from_db = os.path.join(temp_dir, 'from_db') + os.rename(fbc_dir, catalog_from_db) + + # Merge migrated FBC with existing FBC in Git repo + # overwrite data in `catalog_from_index` by data from `catalog_from_db` + # this adds changes on not opted in operators to final FBC + log.info('Merging migrated catalog with Git catalog') + merge_catalogs_dirs(catalog_from_db, localized_git_catalog_path) + + # We need to regenerate file-based catalog because we merged changes + fbc_dir_path = os.path.join(temp_dir, 'catalog') + if os.path.exists(fbc_dir_path): + shutil.rmtree(fbc_dir_path) + # Copy catalog to correct location expected in Dockerfile + # Use copytree instead of move to preserve the configs directory in Git repo + shutil.copytree(localized_git_catalog_path, fbc_dir_path) + + # Validate the FBC config + set_request_state(request_id, 'in_progress', 'Validating the FBC config') + opm_validate(fbc_dir_path) + + # Write build metadata to a file to be added with the commit + set_request_state(request_id, 'in_progress', 'Writing build metadata') + arches = set(prebuild_info['arches']) + write_build_metadata( + local_git_repo_path, + opm_version, + prebuild_info['target_ocp_version'], + prebuild_info['distribution_scope'], + prebuild_info['binary_image_resolved'], + request_id, + arches, + ) + + try: + # Commit changes and create PR or push directly + mr_details, last_commit_sha = git_commit_and_create_mr_or_push( + request_id=request_id, + local_git_repo_path=local_git_repo_path, + index_git_repo=index_git_repo, + branch=branch, + commit_message=( + f"IIB: Merge operators for request {request_id}\n\n" + f"Missing bundles: {', '.join(missing_bundle_paths)}" + ), + overwrite_from_index=overwrite_target_index, + ) + + # Wait for Konflux pipeline and extract built image UR + image_url = monitor_pipeline_and_extract_image( + request_id=request_id, + last_commit_sha=last_commit_sha, + ) + + # Copy built index to all output pull specs + output_pull_specs = replicate_image_to_tagged_destinations( + request_id=request_id, + image_url=image_url, + build_tags=build_tags, + ) + + # Use the first output_pull_spec as the primary one for request updates + output_pull_spec = output_pull_specs[0] + # Update request with final output + if not output_pull_spec: + raise IIBError( + "output_pull_spec was not set. " + "This should not happen if the pipeline completed successfully." + ) + + _update_index_image_pull_spec( + output_pull_spec=output_pull_spec, + request_id=request_id, + arches=prebuild_info['arches'], + from_index=source_from_index, + overwrite_from_index=overwrite_target_index, + overwrite_from_index_token=overwrite_target_index_token, + resolved_prebuild_from_index=source_from_index_resolved, + add_or_rm=True, + is_image_fbc=True, + # Passing an empty index_repo_map is intentional. In IIB 1.0, if + # the overwrite_from_index token is given, we push to git by default + # at the end of a request. In IIB 2.0, the commit is pushed earlier to trigger + # a Konflux pipelinerun. So the old workflow isn't needed. + index_repo_map={}, + ) + + # Push updated index.db if overwrite_target_index_token is provided + # We can push it directly from temp_dir since we're still inside the + # context manager. Do it as the last step to avoid rolling back the + # index.db file if the pipeline fails. + original_index_db_digest = push_index_db_artifact( + request_id=request_id, + from_index=source_from_index, + index_db_path=source_index_db_path, + operators=operators_in_db, + overwrite_from_index=overwrite_target_index, + request_type='merge', + ) + + # Close MR if it was opened + cleanup_merge_request_if_exists(mr_details, index_git_repo) + + # Update request with final output + set_request_state( + request_id, + 'complete', + f"The operator(s) {operators_in_db} were successfully merged " + "from the target index image into the source index image", + ) + except Exception as e: + cleanup_on_failure( + mr_details=mr_details, + last_commit_sha=last_commit_sha, + index_git_repo=index_git_repo, + overwrite_from_index=overwrite_target_index, + request_id=request_id, + from_index=source_from_index, + index_repo_map={}, + original_index_db_digest=original_index_db_digest, + reason=f"error: {e}", + ) + # Reset Docker config for the next request. This is a fail safe. + reset_docker_config() + raise IIBError(f"Failed to merge operators: {e}") diff --git a/iib/workers/tasks/build_merge_index_image.py b/iib/workers/tasks/build_merge_index_image.py index 7b8906ceb..01bad94e2 100644 --- a/iib/workers/tasks/build_merge_index_image.py +++ b/iib/workers/tasks/build_merge_index_image.py @@ -86,53 +86,36 @@ def _filter_out_pure_fbc_bundles( return res_bundles, res_pullspec -def _add_bundles_missing_in_source( +def get_missing_bundles_from_target_to_source( source_index_bundles: List[BundleImage], target_index_bundles: List[BundleImage], - base_dir: str, - binary_image: str, source_from_index: str, - request_id: int, - arch: str, ocp_version: str, - distribution_scope: str, - graph_update_mode: Optional[str] = None, target_index=None, - overwrite_target_index_token: Optional[str] = None, ignore_bundle_ocp_version: Optional[bool] = False, ) -> Tuple[List[BundleImage], List[BundleImage]]: """ - Rebuild index image with bundles missing from source image but present in target image. + Generate a list of missing bundles from the source but present in the target. - If no bundles are missing in the source index image, the index image is still rebuilt - using the new binary image. + This function will not build the index image, it will only generate a list of bundles missing + from the source index image but present in the target index image, as well as a list of bundles + in the new index whose ocp_version range does not satisfy the ocp_version value of the target + index. :param list source_index_bundles: bundles present in the source index image. :param list target_index_bundles: bundles present in the target index image. - :param str base_dir: base directory where operation files will be located. - :param str binary_image: binary image to be used by the new index image. :param str source_from_index: index image, whose data will be contained in the new index image. - :param int request_id: the ID of the IIB build request. - :param str arch: the architecture to build this image for. :param str ocp_version: ocp version which will be added as a label to the image. - :param str graph_update_mode: Graph update mode that defines how channel graphs are updated - in the index. :param str target_index: the pull specification of the container image - :param str overwrite_target_index_token: the token used for overwriting the input - ``source_from_index`` image. This is required to use ``overwrite_target_index``. - The format of the token must be in the format "user:password". :param bool ignore_bundle_ocp_version: When set to `true` and image set as target_index is listed in `iib_no_ocp_label_allow_list` config then bundles without "com.redhat.openshift.versions" label set will be added in the result `index_image`. - :return: tuple where the first value is a list of bundles which were added to the index image - and the second value is a list of bundles in the new index whose ocp_version range does not - satisfy the ocp_version value of the target index. + :return: tuple where the first value is a list of bundles missing in the source and are present + in the target index image and the second value is a list of bundles whose ocp_version + range does not satisfy the ocp_version value of the target index. :rtype: tuple """ - set_request_state(request_id, 'in_progress', 'Adding bundles missing in source index image') - log.info('Adding bundles from target index image which are missing from source index image') missing_bundles = [] - missing_bundle_paths = [] # This list stores the bundles whose ocp_version range does not satisfy the ocp_version # of the target index invalid_bundles = [] @@ -162,7 +145,6 @@ def _add_bundles_missing_in_source( and bundle['csvName'] not in source_bundle_csv_names ): missing_bundles.append(bundle) - missing_bundle_paths.append(bundle['bundlePath']) if ignore_bundle_ocp_version: target_index_tmp = '' if target_index is None else target_index @@ -187,6 +169,65 @@ def _add_bundles_missing_in_source( '%s bundles have invalid version label and will be deprecated.', len(invalid_bundles) ) + return missing_bundles, invalid_bundles + + +def _add_bundles_missing_in_source( + source_index_bundles: List[BundleImage], + target_index_bundles: List[BundleImage], + base_dir: str, + binary_image: str, + source_from_index: str, + request_id: int, + arch: str, + ocp_version: str, + distribution_scope: str, + graph_update_mode: Optional[str] = None, + target_index=None, + overwrite_target_index_token: Optional[str] = None, + ignore_bundle_ocp_version: Optional[bool] = False, +) -> Tuple[List[BundleImage], List[BundleImage]]: + """ + Rebuild index image with bundles missing from source image but present in target image. + + If no bundles are missing in the source index image, the index image is still rebuilt + using the new binary image. + + :param list source_index_bundles: bundles present in the source index image. + :param list target_index_bundles: bundles present in the target index image. + :param str base_dir: base directory where operation files will be located. + :param str binary_image: binary image to be used by the new index image. + :param str source_from_index: index image, whose data will be contained in the new index image. + :param int request_id: the ID of the IIB build request. + :param str arch: the architecture to build this image for. + :param str ocp_version: ocp version which will be added as a label to the image. + :param str graph_update_mode: Graph update mode that defines how channel graphs are updated + in the index. + :param str target_index: the pull specification of the container image + :param str overwrite_target_index_token: the token used for overwriting the input + ``source_from_index`` image. This is required to use ``overwrite_target_index``. + The format of the token must be in the format "user:password". + :param bool ignore_bundle_ocp_version: When set to `true` and image set as target_index is + listed in `iib_no_ocp_label_allow_list` config then bundles without + "com.redhat.openshift.versions" label set will be added in the result `index_image`. + :return: tuple where the first value is a list of bundles which were added to the index image + and the second value is a list of bundles in the new index whose ocp_version range does not + satisfy the ocp_version value of the target index. + :rtype: tuple + """ + set_request_state(request_id, 'in_progress', 'Adding bundles missing in source index image') + log.info('Adding bundles from target index image which are missing from source index image') + + missing_bundles, invalid_bundles = get_missing_bundles_from_target_to_source( + source_index_bundles=source_index_bundles, + target_index_bundles=target_index_bundles, + source_from_index=source_from_index, + ocp_version=ocp_version, + target_index=target_index, + ignore_bundle_ocp_version=ignore_bundle_ocp_version, + ) + missing_bundle_paths = [bundle['bundlePath'] for bundle in missing_bundles] + with set_registry_token(overwrite_target_index_token, target_index, append=True): is_source_fbc = is_image_fbc(source_from_index) if is_source_fbc: diff --git a/iib/workers/tasks/containerized_utils.py b/iib/workers/tasks/containerized_utils.py index c43fad200..79d270252 100644 --- a/iib/workers/tasks/containerized_utils.py +++ b/iib/workers/tasks/containerized_utils.py @@ -5,7 +5,7 @@ import os import queue import threading -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple, Union from iib.exceptions import IIBError from iib.workers.api_utils import set_request_state @@ -62,10 +62,16 @@ def run(self) -> None: try: while not self.bundles_queue.empty(): bundle = self.bundles_queue.get() - skopeo_inspect(f'docker://{bundle}', '--raw', return_json=False) + b_path = str(bundle["bundlePath"]) if isinstance(bundle, dict) else str(bundle) + skopeo_inspect(f'docker://{b_path}', '--raw', return_json=False) except IIBError as e: self.bundle = bundle - log.error(f"Error validating bundle {bundle}: {e}") + bundle_str = ( + bundle["bundlePath"] + if bundle and isinstance(bundle, dict) and "bundlePath" in bundle + else bundle + ) + log.error(f"Error validating bundle {bundle_str}: {e}") self.exception = e finally: while not self.bundles_queue.empty(): @@ -81,24 +87,27 @@ def wait_for_bundle_validation_threads(validation_threads: List[ValidateBundlesT for t in validation_threads: t.join() if t.exception: - bundle_str = str(t.bundle) if t.bundle else "unknown" + if t.bundle and isinstance(t.bundle, dict) and "bundlePath" in t.bundle: + bundle_str = t.bundle["bundlePath"] + else: + bundle_str = str(t.bundle) if t.bundle else "unknown" log.error(f"Error validating bundle {bundle_str}: {t.exception}") raise IIBError(f"Error validating bundle {bundle_str}: {t.exception}") def validate_bundles_in_parallel( - bundles: List[BundleImage], threads=5, wait=True + bundles: Union[List[BundleImage], List[str]], threads=5, wait=True ) -> Optional[List[ValidateBundlesThread]]: """ Validate bundles in parallel. - :param list bundles: the list of bundles to validate + :param list bundles: the list of bundles or bundle pullspecsto validate :param int threads: the number of threads to use :param bool wait: whether to wait for all threads to complete :return: the list of threads if not waiting, None otherwise :rtype: Optional[List[ValidateBundlesThread]] """ - bundles_queue: queue.Queue[BundleImage] = queue.Queue() + bundles_queue: queue.Queue[Union[BundleImage, str]] = queue.Queue() for bundle in bundles: bundles_queue.put(bundle) diff --git a/iib/workers/tasks/opm_operations.py b/iib/workers/tasks/opm_operations.py index 2283fd903..19e95b673 100644 --- a/iib/workers/tasks/opm_operations.py +++ b/iib/workers/tasks/opm_operations.py @@ -500,24 +500,19 @@ def opm_registry_deprecatetruncate(base_dir: str, index_db: str, bundles: List[s run_cmd(cmd, {'cwd': base_dir}, exc_msg=f'Failed to deprecate the bundles on {index_db}') -def deprecate_bundles_fbc( - bundles: List[str], +def deprecate_bundles_db( base_dir: str, - binary_image: str, - from_index: str, + index_db_file: str, + bundles: List[str], ) -> None: """ - Deprecate the specified bundles from the FBC index image. - - Dockerfile is created only, no build is performed. + Deprecate the specified bundles from the index.db file. - :param list bundles: pull specifications of bundles to deprecate. :param str base_dir: base directory where operation files will be located. - :param str binary_image: binary image to be used by the new index image. - :param str from_index: index image, from which the bundles will be deprecated. + :param str index_db_file: path to index.db file used with opm registry deprecatetruncate. + :param list bundles: pull specifications of bundles to deprecate. """ conf = get_worker_config() - index_db_file = _get_or_create_temp_index_db_file(base_dir=base_dir, from_index=from_index) # Break the bundles into chunks of at max iib_deprecate_bundles_limit bundles for i in range( @@ -531,6 +526,27 @@ def deprecate_bundles_fbc( bundles=bundles[i : i + conf.iib_deprecate_bundles_limit], # Pass a chunk starting at i ) + +def deprecate_bundles_fbc( + bundles: List[str], + base_dir: str, + binary_image: str, + from_index: str, +) -> None: + """ + Deprecate the specified bundles from the FBC index image. + + Dockerfile is created only, no build is performed. + + :param list bundles: pull specifications of bundles to deprecate. + :param str base_dir: base directory where operation files will be located. + :param str binary_image: binary image to be used by the new index image. + :param str from_index: index image, from which the bundles will be deprecated. + """ + index_db_file = _get_or_create_temp_index_db_file(base_dir=base_dir, from_index=from_index) + + deprecate_bundles_db(base_dir=base_dir, index_db_file=index_db_file, bundles=bundles) + fbc_dir, _ = opm_migrate(index_db_file, base_dir) # we should keep generating Dockerfile here # to have the same behavior as we run `opm index deprecatetruncate` with '--generate' option diff --git a/tests/test_web/test_api_v1.py b/tests/test_web/test_api_v1.py index acc006473..fcb7fd0d5 100644 --- a/tests/test_web/test_api_v1.py +++ b/tests/test_web/test_api_v1.py @@ -1969,7 +1969,7 @@ def test_regenerate_add_rm_batch_invalid_input(payload, error_msg, app, auth_env @pytest.mark.parametrize("binary_image", ('binary:image', 'scratch')) @pytest.mark.parametrize('distribution_scope', (None, 'stage')) -@mock.patch('iib.web.api_v1.handle_merge_request') +@mock.patch('iib.web.api_v1.handle_containerized_merge_request') @mock.patch('iib.web.api_v1.messaging.send_message_for_state_change') def test_merge_index_image_success( mock_smfsc, mock_merge, binary_image, app, db, auth_env, client, distribution_scope @@ -2032,7 +2032,7 @@ def test_merge_index_image_success( mock_smfsc.assert_called_once_with(mock.ANY, new_batch_msg=True) -@mock.patch('iib.web.api_v1.handle_merge_request') +@mock.patch('iib.web.api_v1.handle_containerized_merge_request') @mock.patch('iib.web.api_v1.messaging.send_message_for_state_change') def test_merge_index_image_overwrite_token_redacted( mock_smfsc, mock_merge, app, auth_env, client, db @@ -2083,7 +2083,7 @@ def test_merge_index_image_overwrite_token_redacted( ({'not.tbrady@DOMAIN.LOCAL': 'Patriots'}, True, None), ), ) -@mock.patch('iib.web.api_v1.handle_merge_request') +@mock.patch('iib.web.api_v1.handle_containerized_merge_request') @mock.patch('iib.web.api_v1.messaging.send_message_for_state_change') def test_merge_index_image_custom_user_queue( mock_smfsc, @@ -2119,7 +2119,7 @@ def test_merge_index_image_custom_user_queue( @pytest.mark.parametrize('overwrite_from_index', (True, False)) -@mock.patch('iib.web.api_v1.handle_merge_request') +@mock.patch('iib.web.api_v1.handle_containerized_merge_request') @mock.patch('iib.web.api_v1.messaging.send_message_for_state_change') def test_merge_index_image_fail_on_missing_overwrite_params( mock_smfsc, mock_merge, app, auth_env, client, overwrite_from_index @@ -2190,7 +2190,7 @@ def test_merge_index_image_fail_on_missing_overwrite_params( ), ), ) -@mock.patch('iib.web.api_v1.handle_merge_request') +@mock.patch('iib.web.api_v1.handle_containerized_merge_request') @mock.patch('iib.web.api_v1.messaging.send_message_for_state_change') def test_merge_index_image_fail_on_invalid_params( mock_smfsc, mock_merge, app, auth_env, client, data, error_msg diff --git a/tests/test_workers/test_tasks/test_build.py b/tests/test_workers/test_tasks/test_build.py index 07bfb73a4..31706e094 100644 --- a/tests/test_workers/test_tasks/test_build.py +++ b/tests/test_workers/test_tasks/test_build.py @@ -103,10 +103,15 @@ def test_cleanup(mock_rdc, mock_run_cmd): mock_rdc.assert_called_once_with() +@mock.patch('iib.workers.tasks.containerized_utils.get_worker_config') @mock.patch('iib.workers.tasks.build.tempfile.TemporaryDirectory') @mock.patch('iib.workers.tasks.build.run_cmd') @mock.patch('iib.workers.tasks.build.open') -def test_create_and_push_manifest_list(mock_open, mock_run_cmd, mock_td, tmp_path): +def test_create_and_push_manifest_list(mock_open, mock_run_cmd, mock_td, mock_gwc, tmp_path): + mock_gwc.return_value = { + 'iib_registry': 'registry:8443', + 'iib_image_push_template': '{registry}/iib-build:{request_id}', + } mock_td.return_value.__enter__.return_value = tmp_path mock_run_cmd.side_effect = [ IIBError('Manifest list not found locally.'), diff --git a/tests/test_workers/test_tasks/test_build_containerized_merge.py b/tests/test_workers/test_tasks/test_build_containerized_merge.py new file mode 100644 index 000000000..aaf35e194 --- /dev/null +++ b/tests/test_workers/test_tasks/test_build_containerized_merge.py @@ -0,0 +1,1873 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +import os +import pytest +from unittest import mock + +from iib.exceptions import IIBError +from iib.workers.tasks import build_containerized_merge +from iib.workers.tasks.utils import RequestConfigMerge + + +# Store original set before mocking +_original_set = set + + +def _mock_set_for_bundles(iterable): + """Define a helper to handle set() calls on lists of dictionaries (bundles).""" + if not iterable: + return _original_set() + # Convert to list if needed + if not isinstance(iterable, (list, tuple)): + iterable = list(iterable) + if len(iterable) > 0 and isinstance(iterable[0], dict): + # For bundles (dicts), deduplicate based on bundlePath + seen_paths = [] + result = [] + for item in iterable: + bundle_path = item.get('bundlePath', str(item)) + if bundle_path not in seen_paths: + seen_paths.append(bundle_path) + result.append(item) + + # Return a set-like object that can be converted to list + class SetLike: + def __init__(self, items): + self.items = items + + def __iter__(self): + return iter(self.items) + + def __len__(self): + return len(self.items) + + return SetLike(result) + # For other types, use the real set + return _original_set(iterable) + + +@mock.patch('iib.workers.tasks.build_containerized_merge.reset_docker_config') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_on_failure') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_merge_request_if_exists') +@mock.patch('iib.workers.tasks.build_containerized_merge.push_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_pull_spec') +@mock.patch('iib.workers.tasks.build_containerized_merge.replicate_image_to_tagged_destinations') +@mock.patch('iib.workers.tasks.build_containerized_merge.monitor_pipeline_and_extract_image') +@mock.patch('iib.workers.tasks.build_containerized_merge.git_commit_and_create_mr_or_push') +@mock.patch('iib.workers.tasks.build_containerized_merge.write_build_metadata') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_validate') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.copytree') +@mock.patch('iib.workers.tasks.build_containerized_merge.merge_catalogs_dirs') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_migrate') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_list_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.deprecate_bundles_db') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_latest_version') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_from_deprecation_list') +@mock.patch('iib.workers.tasks.build_containerized_merge._opm_registry_add') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_missing_bundles_from_target_to_source') +@mock.patch('iib.workers.tasks.build_containerized_merge.validate_bundles_in_parallel') +@mock.patch('iib.workers.tasks.build_containerized_merge._get_present_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.fetch_and_verify_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_git_repository_for_build') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_build_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.Opm') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_request_for_build') +@mock.patch('iib.workers.api_utils.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_registry_token') +@mock.patch('iib.workers.tasks.build_containerized_merge.tempfile.TemporaryDirectory') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.rename') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.rmtree') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.move') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.path.exists') +@mock.patch('builtins.set', side_effect=_mock_set_for_bundles) +def test_handle_containerized_merge_request_success( + mock_set, + mock_exists, + mock_move, + mock_rmtree, + mock_rename, + mock_tempdir, + mock_set_registry_token, + mock_srs, + mock_srs_api, + mock_prfb, + mock_opm, + mock_uiibs, + mock_pgrfb, + mock_favida, + mock_gpb, + mock_vbip, + mock_gmbfts, + mock_ora, + mock_gbfdl, + mock_gblv, + mock_dbd, + mock_glb, + mock_om, + mock_mcd, + mock_copytree, + mock_ov, + mock_wbm, + mock_gccmop, + mock_mpaei, + mock_ritd, + mock_uiips, + mock_pida, + mock_cmrif, + mock_cof, + mock_rdc, +): + """Test successful merge request with all operations.""" + # Setup + request_id = 1 + source_from_index = 'quay.io/namespace/source-index:v4.14' + target_index = 'quay.io/namespace/target-index:v4.15' + deprecation_list = ['bundle1:1.0', 'bundle2:2.0'] + binary_image = 'registry.io/binary:latest' + overwrite_target_index_token = 'user:token' + + # Mock temp directory + temp_dir = '/tmp/iib-1-test' + mock_tempdir.return_value.__enter__.return_value = temp_dir + + # Mock prepare_request_for_build + prebuild_info = { + 'arches': {'amd64', 's390x'}, + 'binary_image_resolved': 'registry.io/binary@sha256:abc123', + 'source_from_index_resolved': 'quay.io/namespace/source-index@sha256:def456', + 'target_index_resolved': 'quay.io/namespace/target-index@sha256:ghi789', + 'ocp_version': 'v4.14', + 'target_ocp_version': 'v4.15', + 'distribution_scope': 'prod', + } + mock_prfb.return_value = prebuild_info + + # Mock OPM + mock_opm.opm_version = 'v1.28.0' + + # Mock git repository setup + index_git_repo = 'https://gitlab.com/repo' + local_git_repo_path = os.path.join(temp_dir, 'git_repo') + localized_git_catalog_path = os.path.join(local_git_repo_path, 'catalogs') + mock_pgrfb.return_value = (index_git_repo, local_git_repo_path, localized_git_catalog_path) + + # Mock index.db artifacts + source_index_db_path = os.path.join(temp_dir, 'source_index.db') + target_index_db_path = os.path.join(temp_dir, 'target_index.db') + mock_favida.side_effect = [source_index_db_path, target_index_db_path] + + # Mock bundles + source_bundles = [ + {'bundlePath': 'bundle1@sha256:111', 'csvName': 'bundle1-1.0', 'packageName': 'bundle1'}, + {'bundlePath': 'bundle2@sha256:222', 'csvName': 'bundle2-2.0', 'packageName': 'bundle2'}, + ] + source_bundles_pull_spec = ['bundle1@sha256:111', 'bundle2@sha256:222'] + target_bundles = [ + {'bundlePath': 'bundle3@sha256:333', 'csvName': 'bundle3-3.0', 'packageName': 'bundle3'}, + {'bundlePath': 'bundle4@sha256:444', 'csvName': 'bundle4-4.0', 'packageName': 'bundle4'}, + ] + target_bundles_pull_spec = ['bundle3@sha256:333', 'bundle4@sha256:444'] + mock_gpb.side_effect = [ + (source_bundles, source_bundles_pull_spec), + (target_bundles, target_bundles_pull_spec), + ] + + # Mock missing bundles + missing_bundles = [ + {'bundlePath': 'bundle3@sha256:333', 'csvName': 'bundle3-3.0', 'packageName': 'bundle3'}, + ] + invalid_bundles = [] + mock_gmbfts.return_value = (missing_bundles, invalid_bundles) + + # Mock deprecation bundles + mock_gbfdl.return_value = ['bundle1:1.0'] + mock_gblv.return_value = ['bundle1:1.0'] + + # Mock FBC migration + fbc_dir = os.path.join(temp_dir, 'fbc_catalog') + mock_om.return_value = (fbc_dir, None) + + # Mock bundles in DB + bundles_in_db = [ + {'bundlePath': 'bundle1@sha256:111', 'packageName': 'bundle1'}, + {'bundlePath': 'bundle3@sha256:333', 'packageName': 'bundle3'}, + ] + mock_glb.return_value = bundles_in_db + + # Mock file system operations + mock_exists.return_value = True + + # Mock git commit + mr_details = None + last_commit_sha = 'abc123commit' + mock_gccmop.return_value = (mr_details, last_commit_sha) + + # Mock Konflux pipeline + image_url = 'quay.io/konflux/built-image@sha256:xyz789' + mock_mpaei.return_value = image_url + + # Mock image replication + output_pull_specs = ['quay.io/iib/iib-build:1'] + mock_ritd.return_value = output_pull_specs + + # Mock index.db push + original_index_db_digest = 'sha256:original123' + mock_pida.return_value = original_index_db_digest + + # Test + build_containerized_merge.handle_containerized_merge_request( + source_from_index=source_from_index, + deprecation_list=deprecation_list, + request_id=request_id, + binary_image=binary_image, + target_index=target_index, + overwrite_target_index=True, + overwrite_target_index_token=overwrite_target_index_token, + distribution_scope='prod', + index_to_gitlab_push_map={'quay.io/namespace/source-index': index_git_repo}, + ) + + # Verify prepare_request_for_build was called + mock_prfb.assert_called_once_with( + request_id, + RequestConfigMerge( + _binary_image=binary_image, + overwrite_target_index_token=overwrite_target_index_token, + source_from_index=source_from_index, + target_index=target_index, + distribution_scope='prod', + binary_image_config=None, + ), + ) + + # Verify OPM version was set + mock_opm.set_opm_version.assert_called_once_with(prebuild_info['target_index_resolved']) + + # Verify git repository was prepared + mock_pgrfb.assert_called_once() + + # Verify index.db artifacts were fetched + assert mock_favida.call_count == 2 + + # Verify bundles were retrieved + assert mock_gpb.call_count == 2 + + # Verify bundles were validated + mock_vbip.assert_called_once() + # Verify it was called with List[str] format (pullspec strings) + call_args = mock_vbip.call_args + bundles_arg = call_args[0][0] if call_args[0] else call_args[1]['bundles'] + assert isinstance(bundles_arg, list) + # All items should be strings (pullspecs), not BundleImage dicts + assert all(isinstance(b, str) for b in bundles_arg) + # Verify expected bundles are in the list + expected_bundles = set(source_bundles_pull_spec + target_bundles_pull_spec) + assert set(bundles_arg) == expected_bundles + + # Verify missing bundles were identified + mock_gmbfts.assert_called_once() + + # Verify missing bundles were added + mock_ora.assert_called_once() + + # Verify deprecation was processed + mock_dbd.assert_called_once() + + # Verify FBC migration + mock_om.assert_called_once() + + # Verify catalog merge + mock_mcd.assert_called_once() + + # Verify FBC validation + mock_ov.assert_called_once() + + # Verify build metadata was written + mock_wbm.assert_called_once() + + # Verify git commit/push + mock_gccmop.assert_called_once() + + # Verify pipeline monitoring + mock_mpaei.assert_called_once() + + # Verify image replication + mock_ritd.assert_called_once() + + # Verify index.db push + mock_pida.assert_called_once() + + # Verify final state + final_call = mock_srs.call_args_list[-1] + assert final_call[0][0] == request_id + assert final_call[0][1] == 'complete' + assert 'successfully merged' in final_call[0][2] + + # Verify reset_docker_config was called + assert mock_rdc.call_count >= 1 + + +@mock.patch('iib.workers.tasks.build_containerized_merge.reset_docker_config') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_on_failure') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_merge_request_if_exists') +@mock.patch('iib.workers.tasks.build_containerized_merge.push_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_pull_spec') +@mock.patch('iib.workers.tasks.build_containerized_merge.replicate_image_to_tagged_destinations') +@mock.patch('iib.workers.tasks.build_containerized_merge.monitor_pipeline_and_extract_image') +@mock.patch('iib.workers.tasks.build_containerized_merge.git_commit_and_create_mr_or_push') +@mock.patch('iib.workers.tasks.build_containerized_merge.write_build_metadata') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_validate') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.copytree') +@mock.patch('iib.workers.tasks.build_containerized_merge.merge_catalogs_dirs') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_migrate') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_list_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.deprecate_bundles_db') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_latest_version') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_from_deprecation_list') +@mock.patch('iib.workers.tasks.build_containerized_merge._opm_registry_add') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_missing_bundles_from_target_to_source') +@mock.patch('iib.workers.tasks.build_containerized_merge.validate_bundles_in_parallel') +@mock.patch('iib.workers.tasks.build_containerized_merge._get_present_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.fetch_and_verify_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_git_repository_for_build') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_build_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.Opm') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_request_for_build') +@mock.patch('iib.workers.api_utils.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_registry_token') +@mock.patch('iib.workers.tasks.build_containerized_merge.tempfile.TemporaryDirectory') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.rename') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.rmtree') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.move') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.path.exists') +@mock.patch('builtins.set', side_effect=_mock_set_for_bundles) +def test_handle_containerized_merge_request_success_with_deprecations( + mock_set, + mock_exists, + mock_move, + mock_rmtree, + mock_rename, + mock_tempdir, + mock_set_registry_token, + mock_srs, + mock_srs_api, + mock_prfb, + mock_opm, + mock_uiibs, + mock_pgrfb, + mock_favida, + mock_gpb, + mock_vbip, + mock_gmbfts, + mock_ora, + mock_gbfdl, + mock_gblv, + mock_dbd, + mock_glb, + mock_om, + mock_mcd, + mock_copytree, + mock_ov, + mock_wbm, + mock_gccmop, + mock_mpaei, + mock_ritd, + mock_uiips, + mock_pida, + mock_cmrif, + mock_cof, + mock_rdc, +): + """Test successful merge request with deprecations executed correctly.""" + # Setup + request_id = 9 + source_from_index = 'quay.io/namespace/source-index:v4.14' + target_index = 'quay.io/namespace/target-index:v4.15' + deprecation_list = ['bundle1:1.0', 'bundle2:2.0'] + binary_image = 'registry.io/binary:latest' + overwrite_target_index_token = 'user:token' + + # Mock temp directory + temp_dir = '/tmp/iib-9-test' + mock_tempdir.return_value.__enter__.return_value = temp_dir + + # Mock prepare_request_for_build + prebuild_info = { + 'arches': {'amd64', 's390x'}, + 'binary_image_resolved': 'registry.io/binary@sha256:abc123', + 'source_from_index_resolved': 'quay.io/namespace/source-index@sha256:def456', + 'target_index_resolved': 'quay.io/namespace/target-index@sha256:ghi789', + 'ocp_version': 'v4.14', + 'target_ocp_version': 'v4.15', + 'distribution_scope': 'prod', + } + mock_prfb.return_value = prebuild_info + + # Mock OPM + mock_opm.opm_version = 'v1.28.0' + + # Mock git repository setup + index_git_repo = 'https://gitlab.com/repo' + local_git_repo_path = os.path.join(temp_dir, 'git_repo') + localized_git_catalog_path = os.path.join(local_git_repo_path, 'catalogs') + mock_pgrfb.return_value = (index_git_repo, local_git_repo_path, localized_git_catalog_path) + + # Mock index.db artifacts + source_index_db_path = os.path.join(temp_dir, 'source_index.db') + target_index_db_path = os.path.join(temp_dir, 'target_index.db') + mock_favida.side_effect = [source_index_db_path, target_index_db_path] + + # Mock bundles + source_bundles = [ + {'bundlePath': 'bundle1@sha256:111', 'csvName': 'bundle1-1.0', 'packageName': 'bundle1'}, + {'bundlePath': 'bundle2@sha256:222', 'csvName': 'bundle2-2.0', 'packageName': 'bundle2'}, + {'bundlePath': 'bundle3@sha256:333', 'csvName': 'bundle3-3.0', 'packageName': 'bundle3'}, + ] + source_bundles_pull_spec = ['bundle1@sha256:111', 'bundle2@sha256:222', 'bundle3@sha256:333'] + target_bundles = [ + {'bundlePath': 'bundle4@sha256:444', 'csvName': 'bundle4-4.0', 'packageName': 'bundle4'}, + ] + target_bundles_pull_spec = ['bundle4@sha256:444'] + mock_gpb.side_effect = [ + (source_bundles, source_bundles_pull_spec), + (target_bundles, target_bundles_pull_spec), + ] + + # Mock missing bundles + missing_bundles = [ + {'bundlePath': 'bundle4@sha256:444', 'csvName': 'bundle4-4.0', 'packageName': 'bundle4'}, + ] + invalid_bundles = [] + mock_gmbfts.return_value = (missing_bundles, invalid_bundles) + + # Mock deprecation bundles - these should be found from the deprecation_list + deprecation_bundles_from_list = ['bundle1@sha256:111', 'bundle2@sha256:222'] + deprecation_bundles_latest = ['bundle1@sha256:111', 'bundle2@sha256:222'] + mock_gbfdl.return_value = deprecation_bundles_from_list + mock_gblv.return_value = deprecation_bundles_latest + + # Mock FBC migration + fbc_dir = os.path.join(temp_dir, 'fbc_catalog') + mock_om.return_value = (fbc_dir, None) + + # Mock bundles in DB + bundles_in_db = [ + {'bundlePath': 'bundle1@sha256:111', 'packageName': 'bundle1'}, + {'bundlePath': 'bundle2@sha256:222', 'packageName': 'bundle2'}, + {'bundlePath': 'bundle3@sha256:333', 'packageName': 'bundle3'}, + {'bundlePath': 'bundle4@sha256:444', 'packageName': 'bundle4'}, + ] + mock_glb.return_value = bundles_in_db + + # Mock file system operations + mock_exists.return_value = True + + # Mock git commit + mr_details = None + last_commit_sha = 'abc123commit' + mock_gccmop.return_value = (mr_details, last_commit_sha) + + # Mock Konflux pipeline + image_url = 'quay.io/konflux/built-image@sha256:xyz789' + mock_mpaei.return_value = image_url + + # Mock image replication + output_pull_specs = ['quay.io/iib/iib-build:9'] + mock_ritd.return_value = output_pull_specs + + # Mock index.db push + original_index_db_digest = 'sha256:original123' + mock_pida.return_value = original_index_db_digest + + # Test + build_containerized_merge.handle_containerized_merge_request( + source_from_index=source_from_index, + deprecation_list=deprecation_list, + request_id=request_id, + binary_image=binary_image, + target_index=target_index, + overwrite_target_index=True, + overwrite_target_index_token=overwrite_target_index_token, + distribution_scope='prod', + index_to_gitlab_push_map={'quay.io/namespace/source-index': index_git_repo}, + ) + + # Verify prepare_request_for_build was called + mock_prfb.assert_called_once_with( + request_id, + RequestConfigMerge( + _binary_image=binary_image, + overwrite_target_index_token=overwrite_target_index_token, + source_from_index=source_from_index, + target_index=target_index, + distribution_scope='prod', + binary_image_config=None, + ), + ) + + # Verify OPM version was set + mock_opm.set_opm_version.assert_called_once_with(prebuild_info['target_index_resolved']) + + # Verify git repository was prepared + mock_pgrfb.assert_called_once() + + # Verify index.db artifacts were fetched + assert mock_favida.call_count == 2 + + # Verify bundles were retrieved + assert mock_gpb.call_count == 2 + + # Verify bundles were validated + mock_vbip.assert_called_once() + # Verify it was called with List[str] format (pullspec strings) + call_args = mock_vbip.call_args + bundles_arg = call_args[0][0] if call_args[0] else call_args[1]['bundles'] + assert isinstance(bundles_arg, list) + # All items should be strings (pullspecs), not BundleImage dicts + assert all(isinstance(b, str) for b in bundles_arg) + # Verify expected bundles are in the list + expected_bundles = set(source_bundles_pull_spec + target_bundles_pull_spec) + assert set(bundles_arg) == expected_bundles + + # Verify missing bundles were identified + mock_gmbfts.assert_called_once() + + # Verify missing bundles were added + mock_ora.assert_called_once() + + # Verify deprecation processing was executed + # 1. get_bundles_from_deprecation_list should be called with + # intermediate_bundles and deprecation_list + mock_gbfdl.assert_called_once() + gbfdl_call_args = mock_gbfdl.call_args + assert deprecation_list == gbfdl_call_args[0][1] + # Verify intermediate_bundles includes missing bundles + source bundles + intermediate_bundles = gbfdl_call_args[0][0] + assert 'bundle4@sha256:444' in intermediate_bundles # missing bundle + assert 'bundle1@sha256:111' in intermediate_bundles # source bundle + + # 2. get_bundles_latest_version should be called with deprecation bundles and all bundles + mock_gblv.assert_called_once() + gblv_call_args = mock_gblv.call_args + assert deprecation_bundles_from_list == gblv_call_args[0][0] + all_bundles = gblv_call_args[0][1] + # Verify all_bundles includes both source and target bundles + assert len(all_bundles) == len(source_bundles) + len(target_bundles) + + # 3. deprecate_bundles_db should be called with the latest deprecation bundles + mock_dbd.assert_called_once() + dbd_call_args = mock_dbd.call_args + assert dbd_call_args[1]['base_dir'] == temp_dir + assert dbd_call_args[1]['index_db_file'] == source_index_db_path + assert dbd_call_args[1]['bundles'] == deprecation_bundles_latest + # Verify the deprecation bundles match what was expected + assert 'bundle1@sha256:111' in dbd_call_args[1]['bundles'] + assert 'bundle2@sha256:222' in dbd_call_args[1]['bundles'] + + # Verify FBC migration + mock_om.assert_called_once() + + # Verify catalog merge + mock_mcd.assert_called_once() + + # Verify FBC validation + mock_ov.assert_called_once() + + # Verify build metadata was written + mock_wbm.assert_called_once() + + # Verify git commit/push + mock_gccmop.assert_called_once() + + # Verify pipeline monitoring + mock_mpaei.assert_called_once() + + # Verify image replication + mock_ritd.assert_called_once() + + # Verify index.db push + mock_pida.assert_called_once() + + # Verify final state - operation completed successfully + final_call = mock_srs.call_args_list[-1] + assert final_call[0][0] == request_id + assert final_call[0][1] == 'complete' + assert 'successfully merged' in final_call[0][2] + + # Verify reset_docker_config was called + assert mock_rdc.call_count >= 1 + + +@mock.patch('iib.workers.tasks.build_containerized_merge.reset_docker_config') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_on_failure') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_merge_request_if_exists') +@mock.patch('iib.workers.tasks.build_containerized_merge.push_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_pull_spec') +@mock.patch('iib.workers.tasks.build_containerized_merge.replicate_image_to_tagged_destinations') +@mock.patch('iib.workers.tasks.build_containerized_merge.monitor_pipeline_and_extract_image') +@mock.patch('iib.workers.tasks.build_containerized_merge.git_commit_and_create_mr_or_push') +@mock.patch('iib.workers.tasks.build_containerized_merge.write_build_metadata') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_validate') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.copytree') +@mock.patch('iib.workers.tasks.build_containerized_merge.merge_catalogs_dirs') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_migrate') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_list_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.deprecate_bundles_db') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_latest_version') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_from_deprecation_list') +@mock.patch('iib.workers.tasks.build_containerized_merge._opm_registry_add') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_missing_bundles_from_target_to_source') +@mock.patch('iib.workers.tasks.build_containerized_merge.validate_bundles_in_parallel') +@mock.patch('iib.workers.tasks.build_containerized_merge._get_present_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.fetch_and_verify_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_git_repository_for_build') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_build_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.Opm') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_request_for_build') +@mock.patch('iib.workers.api_utils.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_registry_token') +@mock.patch('iib.workers.tasks.build_containerized_merge.tempfile.TemporaryDirectory') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.rename') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.rmtree') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.move') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.path.exists') +@mock.patch('builtins.set', side_effect=_mock_set_for_bundles) +def test_handle_containerized_merge_request_with_mr( + mock_set, + mock_exists, + mock_move, + mock_rmtree, + mock_rename, + mock_tempdir, + mock_set_registry_token, + mock_srs, + mock_srs_api, + mock_prfb, + mock_opm, + mock_uiibs, + mock_pgrfb, + mock_favida, + mock_gpb, + mock_vbip, + mock_gmbfts, + mock_ora, + mock_gbfdl, + mock_gblv, + mock_dbd, + mock_glb, + mock_om, + mock_mcd, + mock_copytree, + mock_ov, + mock_wbm, + mock_gccmop, + mock_mpaei, + mock_ritd, + mock_uiips, + mock_pida, + mock_cmrif, + mock_cof, + mock_rdc, +): + """Test merge request that creates and closes MR.""" + request_id = 2 + source_from_index = 'quay.io/namespace/source-index:v4.14' + target_index = 'quay.io/namespace/target-index:v4.15' + + temp_dir = '/tmp/iib-2-test' + mock_tempdir.return_value.__enter__.return_value = temp_dir + + prebuild_info = { + 'arches': {'amd64'}, + 'binary_image_resolved': 'registry.io/binary@sha256:abc', + 'source_from_index_resolved': 'quay.io/namespace/source-index@sha256:def', + 'target_index_resolved': 'quay.io/namespace/target-index@sha256:ghi', + 'ocp_version': 'v4.14', + 'target_ocp_version': 'v4.15', + 'distribution_scope': 'prod', + } + mock_prfb.return_value = prebuild_info + + mock_opm.opm_version = 'v1.28.0' + + index_git_repo = 'https://gitlab.com/repo' + local_git_repo_path = os.path.join(temp_dir, 'git_repo') + localized_git_catalog_path = os.path.join(local_git_repo_path, 'catalogs') + mock_pgrfb.return_value = (index_git_repo, local_git_repo_path, localized_git_catalog_path) + + source_index_db_path = os.path.join(temp_dir, 'source_index.db') + target_index_db_path = os.path.join(temp_dir, 'target_index.db') + mock_favida.side_effect = [source_index_db_path, target_index_db_path] + + source_bundles = [ + {'bundlePath': 'bundle1@sha256:111', 'csvName': 'bundle1-1.0', 'packageName': 'bundle1'} + ] + source_bundles_pull_spec = ['bundle1@sha256:111'] + target_bundles = [ + {'bundlePath': 'bundle2@sha256:222', 'csvName': 'bundle2-2.0', 'packageName': 'bundle2'} + ] + target_bundles_pull_spec = ['bundle2@sha256:222'] + mock_gpb.side_effect = [ + (source_bundles, source_bundles_pull_spec), + (target_bundles, target_bundles_pull_spec), + ] + + missing_bundles = [ + {'bundlePath': 'bundle2@sha256:222', 'csvName': 'bundle2-2.0', 'packageName': 'bundle2'} + ] + mock_gmbfts.return_value = (missing_bundles, []) + + mock_gbfdl.return_value = [] + bundles_in_db = [{'bundlePath': 'bundle1@sha256:111', 'packageName': 'bundle1'}] + mock_glb.return_value = bundles_in_db + + fbc_dir = os.path.join(temp_dir, 'fbc_catalog') + mock_om.return_value = (fbc_dir, None) + + mock_exists.return_value = True + + # Mock MR creation + mr_details = {'mr_url': 'https://gitlab.com/repo/-/merge_requests/1', 'mr_id': 1} + last_commit_sha = 'commit_sha_123' + mock_gccmop.return_value = (mr_details, last_commit_sha) + + image_url = 'quay.io/konflux/image@sha256:built' + mock_mpaei.return_value = image_url + + output_pull_specs = ['quay.io/iib/iib-build:2'] + mock_ritd.return_value = output_pull_specs + + original_index_db_digest = 'sha256:original123' + mock_pida.return_value = original_index_db_digest + + # Test without overwrite_target_index_token (creates MR) + build_containerized_merge.handle_containerized_merge_request( + source_from_index=source_from_index, + deprecation_list=[], + request_id=request_id, + target_index=target_index, + overwrite_target_index=False, + index_to_gitlab_push_map={'quay.io/namespace/source-index': index_git_repo}, + ) + + # Verify MR was created + commit_msg = mock_gccmop.call_args[1]['commit_message'] + assert f'IIB: Merge operators for request {request_id}' in commit_msg + + # Verify completion + final_call = mock_srs.call_args_list[-1] + assert final_call[0][1] == 'complete' + + +@mock.patch('iib.workers.tasks.build_containerized_merge.reset_docker_config') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_on_failure') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_merge_request_if_exists') +@mock.patch('iib.workers.tasks.build_containerized_merge.push_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_pull_spec') +@mock.patch('iib.workers.tasks.build_containerized_merge.replicate_image_to_tagged_destinations') +@mock.patch('iib.workers.tasks.build_containerized_merge.monitor_pipeline_and_extract_image') +@mock.patch('iib.workers.tasks.build_containerized_merge.git_commit_and_create_mr_or_push') +@mock.patch('iib.workers.tasks.build_containerized_merge.write_build_metadata') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_validate') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.copytree') +@mock.patch('iib.workers.tasks.build_containerized_merge.merge_catalogs_dirs') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_migrate') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_list_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.deprecate_bundles_db') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_latest_version') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_from_deprecation_list') +@mock.patch('iib.workers.tasks.build_containerized_merge._opm_registry_add') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_missing_bundles_from_target_to_source') +@mock.patch('iib.workers.tasks.build_containerized_merge.validate_bundles_in_parallel') +@mock.patch('iib.workers.tasks.build_containerized_merge._get_present_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.fetch_and_verify_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_git_repository_for_build') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_build_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.Opm') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_request_for_build') +@mock.patch('iib.workers.api_utils.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_registry_token') +@mock.patch('iib.workers.tasks.build_containerized_merge.tempfile.TemporaryDirectory') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.rename') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.rmtree') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.move') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.path.exists') +@mock.patch('builtins.set', side_effect=_mock_set_for_bundles) +def test_handle_containerized_merge_request_no_missing_bundles( + mock_set, + mock_exists, + mock_move, + mock_rmtree, + mock_rename, + mock_tempdir, + mock_set_registry_token, + mock_srs, + mock_srs_api, + mock_prfb, + mock_opm, + mock_uiibs, + mock_pgrfb, + mock_favida, + mock_gpb, + mock_vbip, + mock_gmbfts, + mock_ora, + mock_gbfdl, + mock_gblv, + mock_dbd, + mock_glb, + mock_om, + mock_mcd, + mock_copytree, + mock_ov, + mock_wbm, + mock_gccmop, + mock_mpaei, + mock_ritd, + mock_uiips, + mock_pida, + mock_cmrif, + mock_cof, + mock_rdc, +): + """Test merge request when no bundles are missing.""" + request_id = 3 + source_from_index = 'quay.io/namespace/source-index:v4.14' + target_index = 'quay.io/namespace/target-index:v4.15' + + temp_dir = '/tmp/iib-3-test' + mock_tempdir.return_value.__enter__.return_value = temp_dir + + prebuild_info = { + 'arches': {'amd64'}, + 'binary_image_resolved': 'registry.io/binary@sha256:abc', + 'source_from_index_resolved': 'quay.io/namespace/source-index@sha256:def', + 'target_index_resolved': 'quay.io/namespace/target-index@sha256:ghi', + 'ocp_version': 'v4.14', + 'target_ocp_version': 'v4.15', + 'distribution_scope': 'prod', + } + mock_prfb.return_value = prebuild_info + + mock_opm.opm_version = 'v1.28.0' + + index_git_repo = 'https://gitlab.com/repo' + local_git_repo_path = os.path.join(temp_dir, 'git_repo') + localized_git_catalog_path = os.path.join(local_git_repo_path, 'catalogs') + mock_pgrfb.return_value = (index_git_repo, local_git_repo_path, localized_git_catalog_path) + + source_index_db_path = os.path.join(temp_dir, 'source_index.db') + target_index_db_path = os.path.join(temp_dir, 'target_index.db') + mock_favida.side_effect = [source_index_db_path, target_index_db_path] + + source_bundles = [ + {'bundlePath': 'bundle1@sha256:111', 'csvName': 'bundle1-1.0', 'packageName': 'bundle1'} + ] + source_bundles_pull_spec = ['bundle1@sha256:111'] + target_bundles = [ + {'bundlePath': 'bundle1@sha256:111', 'csvName': 'bundle1-1.0', 'packageName': 'bundle1'} + ] + target_bundles_pull_spec = ['bundle1@sha256:111'] + mock_gpb.side_effect = [ + (source_bundles, source_bundles_pull_spec), + (target_bundles, target_bundles_pull_spec), + ] + + # No missing bundles + mock_gmbfts.return_value = ([], []) + + mock_gbfdl.return_value = [] + bundles_in_db = [{'bundlePath': 'bundle1@sha256:111', 'packageName': 'bundle1'}] + mock_glb.return_value = bundles_in_db + + fbc_dir = os.path.join(temp_dir, 'fbc_catalog') + mock_om.return_value = (fbc_dir, None) + + mock_exists.return_value = True + + mr_details = None + last_commit_sha = 'commit_sha' + mock_gccmop.return_value = (mr_details, last_commit_sha) + + image_url = 'quay.io/konflux/image@sha256:built' + mock_mpaei.return_value = image_url + + output_pull_specs = ['quay.io/iib/iib-build:3'] + mock_ritd.return_value = output_pull_specs + + original_index_db_digest = 'sha256:original123' + mock_pida.return_value = original_index_db_digest + + # Test + build_containerized_merge.handle_containerized_merge_request( + source_from_index=source_from_index, + deprecation_list=[], + request_id=request_id, + target_index=target_index, + index_to_gitlab_push_map={'quay.io/namespace/source-index': index_git_repo}, + ) + + # Verify _opm_registry_add was called with empty list + mock_ora.assert_called_once() + assert mock_ora.call_args[0][2] == [] + + +@mock.patch('iib.workers.tasks.build_containerized_merge.reset_docker_config') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_on_failure') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_merge_request_if_exists') +@mock.patch('iib.workers.tasks.build_containerized_merge.push_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_pull_spec') +@mock.patch('iib.workers.tasks.build_containerized_merge.replicate_image_to_tagged_destinations') +@mock.patch('iib.workers.tasks.build_containerized_merge.monitor_pipeline_and_extract_image') +@mock.patch('iib.workers.tasks.build_containerized_merge.git_commit_and_create_mr_or_push') +@mock.patch('iib.workers.tasks.build_containerized_merge.write_build_metadata') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_validate') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.copytree') +@mock.patch('iib.workers.tasks.build_containerized_merge.merge_catalogs_dirs') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_migrate') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_list_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.deprecate_bundles_db') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_latest_version') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_from_deprecation_list') +@mock.patch('iib.workers.tasks.build_containerized_merge._opm_registry_add') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_missing_bundles_from_target_to_source') +@mock.patch('iib.workers.tasks.build_containerized_merge.validate_bundles_in_parallel') +@mock.patch('iib.workers.tasks.build_containerized_merge._get_present_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.fetch_and_verify_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_git_repository_for_build') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_build_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.Opm') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_request_for_build') +@mock.patch('iib.workers.api_utils.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_registry_token') +@mock.patch('iib.workers.tasks.build_containerized_merge.tempfile.TemporaryDirectory') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.rename') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.rmtree') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.move') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.path.exists') +@mock.patch('builtins.set', side_effect=_mock_set_for_bundles) +def test_handle_containerized_merge_request_with_deprecation( + mock_set, + mock_exists, + mock_move, + mock_rmtree, + mock_rename, + mock_tempdir, + mock_set_registry_token, + mock_srs, + mock_srs_api, + mock_prfb, + mock_opm, + mock_uiibs, + mock_pgrfb, + mock_favida, + mock_gpb, + mock_vbip, + mock_gmbfts, + mock_ora, + mock_gbfdl, + mock_gblv, + mock_dbd, + mock_glb, + mock_om, + mock_mcd, + mock_copytree, + mock_ov, + mock_wbm, + mock_gccmop, + mock_mpaei, + mock_ritd, + mock_uiips, + mock_pida, + mock_cmrif, + mock_cof, + mock_rdc, +): + """Test merge request with deprecation list.""" + request_id = 4 + source_from_index = 'quay.io/namespace/source-index:v4.14' + target_index = 'quay.io/namespace/target-index:v4.15' + deprecation_list = ['bundle1:1.0', 'bundle2:2.0'] + + temp_dir = '/tmp/iib-4-test' + mock_tempdir.return_value.__enter__.return_value = temp_dir + + prebuild_info = { + 'arches': {'amd64'}, + 'binary_image_resolved': 'registry.io/binary@sha256:abc', + 'source_from_index_resolved': 'quay.io/namespace/source-index@sha256:def', + 'target_index_resolved': 'quay.io/namespace/target-index@sha256:ghi', + 'ocp_version': 'v4.14', + 'target_ocp_version': 'v4.15', + 'distribution_scope': 'prod', + } + mock_prfb.return_value = prebuild_info + + mock_opm.opm_version = 'v1.28.0' + + index_git_repo = 'https://gitlab.com/repo' + local_git_repo_path = os.path.join(temp_dir, 'git_repo') + localized_git_catalog_path = os.path.join(local_git_repo_path, 'catalogs') + mock_pgrfb.return_value = (index_git_repo, local_git_repo_path, localized_git_catalog_path) + + source_index_db_path = os.path.join(temp_dir, 'source_index.db') + target_index_db_path = os.path.join(temp_dir, 'target_index.db') + mock_favida.side_effect = [source_index_db_path, target_index_db_path] + + source_bundles = [ + {'bundlePath': 'bundle1@sha256:111', 'csvName': 'bundle1-1.0', 'packageName': 'bundle1'}, + {'bundlePath': 'bundle2@sha256:222', 'csvName': 'bundle2-2.0', 'packageName': 'bundle2'}, + ] + source_bundles_pull_spec = ['bundle1@sha256:111', 'bundle2@sha256:222'] + target_bundles = [] + target_bundles_pull_spec = [] + mock_gpb.side_effect = [ + (source_bundles, source_bundles_pull_spec), + (target_bundles, target_bundles_pull_spec), + ] + + mock_gmbfts.return_value = ([], []) + + # Mock deprecation bundles + mock_gbfdl.return_value = ['bundle1@sha256:111', 'bundle2@sha256:222'] + mock_gblv.return_value = ['bundle1@sha256:111', 'bundle2@sha256:222'] + + bundles_in_db = [ + {'bundlePath': 'bundle1@sha256:111', 'packageName': 'bundle1'}, + {'bundlePath': 'bundle2@sha256:222', 'packageName': 'bundle2'}, + ] + mock_glb.return_value = bundles_in_db + + fbc_dir = os.path.join(temp_dir, 'fbc_catalog') + mock_om.return_value = (fbc_dir, None) + + mock_exists.return_value = True + + mr_details = None + last_commit_sha = 'commit_sha' + mock_gccmop.return_value = (mr_details, last_commit_sha) + + image_url = 'quay.io/konflux/image@sha256:built' + mock_mpaei.return_value = image_url + + output_pull_specs = ['quay.io/iib/iib-build:4'] + mock_ritd.return_value = output_pull_specs + + original_index_db_digest = 'sha256:original123' + mock_pida.return_value = original_index_db_digest + + # Test + build_containerized_merge.handle_containerized_merge_request( + source_from_index=source_from_index, + deprecation_list=deprecation_list, + request_id=request_id, + target_index=target_index, + index_to_gitlab_push_map={'quay.io/namespace/source-index': index_git_repo}, + ) + + # Verify deprecation was processed + mock_gbfdl.assert_called_once() + mock_gblv.assert_called_once() + mock_dbd.assert_called_once() + + +@mock.patch('iib.workers.tasks.build_containerized_merge.reset_docker_config') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_on_failure') +@mock.patch('iib.workers.tasks.build_containerized_merge.monitor_pipeline_and_extract_image') +@mock.patch('iib.workers.tasks.build_containerized_merge.git_commit_and_create_mr_or_push') +@mock.patch('iib.workers.tasks.build_containerized_merge.write_build_metadata') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_validate') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.copytree') +@mock.patch('iib.workers.tasks.build_containerized_merge.merge_catalogs_dirs') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_migrate') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_list_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.deprecate_bundles_db') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_latest_version') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_from_deprecation_list') +@mock.patch('iib.workers.tasks.build_containerized_merge._opm_registry_add') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_missing_bundles_from_target_to_source') +@mock.patch('iib.workers.tasks.build_containerized_merge.validate_bundles_in_parallel') +@mock.patch('iib.workers.tasks.build_containerized_merge._get_present_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.fetch_and_verify_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_git_repository_for_build') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_build_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.Opm') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_request_for_build') +@mock.patch('iib.workers.api_utils.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_registry_token') +@mock.patch('iib.workers.tasks.build_containerized_merge.tempfile.TemporaryDirectory') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.rename') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.rmtree') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.move') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.path.exists') +@mock.patch('builtins.set', side_effect=_mock_set_for_bundles) +def test_handle_containerized_merge_request_pipeline_failure( + mock_set, + mock_exists, + mock_move, + mock_rmtree, + mock_rename, + mock_tempdir, + mock_set_registry_token, + mock_srs, + mock_srs_api, + mock_prfb, + mock_opm, + mock_uiibs, + mock_pgrfb, + mock_favida, + mock_gpb, + mock_vbip, + mock_gmbfts, + mock_ora, + mock_gbfdl, + mock_gblv, + mock_dbd, + mock_glb, + mock_om, + mock_mcd, + mock_copytree, + mock_ov, + mock_wbm, + mock_gccmop, + mock_mpaei, + mock_cof, + mock_rdc, +): + """Test that pipeline failure triggers cleanup.""" + request_id = 5 + source_from_index = 'quay.io/namespace/source-index:v4.14' + target_index = 'quay.io/namespace/target-index:v4.15' + + temp_dir = '/tmp/iib-5-test' + mock_tempdir.return_value.__enter__.return_value = temp_dir + + prebuild_info = { + 'arches': {'amd64'}, + 'binary_image_resolved': 'registry.io/binary@sha256:abc', + 'source_from_index_resolved': 'quay.io/namespace/source-index@sha256:def', + 'target_index_resolved': 'quay.io/namespace/target-index@sha256:ghi', + 'ocp_version': 'v4.14', + 'target_ocp_version': 'v4.15', + 'distribution_scope': 'prod', + } + mock_prfb.return_value = prebuild_info + + mock_opm.opm_version = 'v1.28.0' + + index_git_repo = 'https://gitlab.com/repo' + local_git_repo_path = os.path.join(temp_dir, 'git_repo') + localized_git_catalog_path = os.path.join(local_git_repo_path, 'catalogs') + mock_pgrfb.return_value = (index_git_repo, local_git_repo_path, localized_git_catalog_path) + + source_index_db_path = os.path.join(temp_dir, 'source_index.db') + target_index_db_path = os.path.join(temp_dir, 'target_index.db') + mock_favida.side_effect = [source_index_db_path, target_index_db_path] + + source_bundles = [ + {'bundlePath': 'bundle1@sha256:111', 'csvName': 'bundle1-1.0', 'packageName': 'bundle1'} + ] + source_bundles_pull_spec = ['bundle1@sha256:111'] + target_bundles = [] + target_bundles_pull_spec = [] + mock_gpb.side_effect = [ + (source_bundles, source_bundles_pull_spec), + (target_bundles, target_bundles_pull_spec), + ] + + mock_gmbfts.return_value = ([], []) + + mock_gbfdl.return_value = [] + bundles_in_db = [{'bundlePath': 'bundle1@sha256:111', 'packageName': 'bundle1'}] + mock_glb.return_value = bundles_in_db + + fbc_dir = os.path.join(temp_dir, 'fbc_catalog') + mock_om.return_value = (fbc_dir, None) + + mock_exists.return_value = True + + mr_details = None + last_commit_sha = 'commit_sha' + mock_gccmop.return_value = (mr_details, last_commit_sha) + + # Mock pipeline to raise error + mock_mpaei.side_effect = IIBError('Pipeline not found') + + # Test + with pytest.raises(IIBError, match='Failed to merge operators'): + build_containerized_merge.handle_containerized_merge_request( + source_from_index=source_from_index, + deprecation_list=[], + request_id=request_id, + target_index=target_index, + index_to_gitlab_push_map={'quay.io/namespace/source-index': index_git_repo}, + ) + + # Verify cleanup was called + mock_cof.assert_called_once() + cleanup_call = mock_cof.call_args + assert cleanup_call[1]['request_id'] == request_id + assert 'Pipeline not found' in cleanup_call[1]['reason'] + + +@mock.patch('iib.workers.tasks.build_containerized_merge.reset_docker_config') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_on_failure') +@mock.patch('iib.workers.tasks.build_containerized_merge.replicate_image_to_tagged_destinations') +@mock.patch('iib.workers.tasks.build_containerized_merge.monitor_pipeline_and_extract_image') +@mock.patch('iib.workers.tasks.build_containerized_merge.git_commit_and_create_mr_or_push') +@mock.patch('iib.workers.tasks.build_containerized_merge.write_build_metadata') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_validate') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.copytree') +@mock.patch('iib.workers.tasks.build_containerized_merge.merge_catalogs_dirs') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_migrate') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_list_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.deprecate_bundles_db') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_latest_version') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_from_deprecation_list') +@mock.patch('iib.workers.tasks.build_containerized_merge._opm_registry_add') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_missing_bundles_from_target_to_source') +@mock.patch('iib.workers.tasks.build_containerized_merge.validate_bundles_in_parallel') +@mock.patch('iib.workers.tasks.build_containerized_merge._get_present_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.fetch_and_verify_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_git_repository_for_build') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_build_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.Opm') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_request_for_build') +@mock.patch('iib.workers.api_utils.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_registry_token') +@mock.patch('iib.workers.tasks.build_containerized_merge.tempfile.TemporaryDirectory') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.rename') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.rmtree') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.move') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.path.exists') +@mock.patch('builtins.set', side_effect=_mock_set_for_bundles) +def test_handle_containerized_merge_request_missing_output_pull_spec( + mock_set, + mock_exists, + mock_move, + mock_rmtree, + mock_rename, + mock_tempdir, + mock_set_registry_token, + mock_srs, + mock_srs_api, + mock_prfb, + mock_opm, + mock_uiibs, + mock_pgrfb, + mock_favida, + mock_gpb, + mock_vbip, + mock_gmbfts, + mock_ora, + mock_gbfdl, + mock_gblv, + mock_dbd, + mock_glb, + mock_om, + mock_mcd, + mock_copytree, + mock_ov, + mock_wbm, + mock_gccmop, + mock_mpaei, + mock_ritd, + mock_cof, + mock_rdc, +): + """Test error when output_pull_spec is not set.""" + request_id = 6 + source_from_index = 'quay.io/namespace/source-index:v4.14' + target_index = 'quay.io/namespace/target-index:v4.15' + + temp_dir = '/tmp/iib-6-test' + mock_tempdir.return_value.__enter__.return_value = temp_dir + + prebuild_info = { + 'arches': {'amd64'}, + 'binary_image_resolved': 'registry.io/binary@sha256:abc', + 'source_from_index_resolved': 'quay.io/namespace/source-index@sha256:def', + 'target_index_resolved': 'quay.io/namespace/target-index@sha256:ghi', + 'ocp_version': 'v4.14', + 'target_ocp_version': 'v4.15', + 'distribution_scope': 'prod', + } + mock_prfb.return_value = prebuild_info + + mock_opm.opm_version = 'v1.28.0' + + index_git_repo = 'https://gitlab.com/repo' + local_git_repo_path = os.path.join(temp_dir, 'git_repo') + localized_git_catalog_path = os.path.join(local_git_repo_path, 'catalogs') + mock_pgrfb.return_value = (index_git_repo, local_git_repo_path, localized_git_catalog_path) + + source_index_db_path = os.path.join(temp_dir, 'source_index.db') + target_index_db_path = os.path.join(temp_dir, 'target_index.db') + mock_favida.side_effect = [source_index_db_path, target_index_db_path] + + source_bundles = [ + {'bundlePath': 'bundle1@sha256:111', 'csvName': 'bundle1-1.0', 'packageName': 'bundle1'} + ] + source_bundles_pull_spec = ['bundle1@sha256:111'] + target_bundles = [] + target_bundles_pull_spec = [] + mock_gpb.side_effect = [ + (source_bundles, source_bundles_pull_spec), + (target_bundles, target_bundles_pull_spec), + ] + + mock_gmbfts.return_value = ([], []) + + mock_gbfdl.return_value = [] + bundles_in_db = [{'bundlePath': 'bundle1@sha256:111', 'packageName': 'bundle1'}] + mock_glb.return_value = bundles_in_db + + fbc_dir = os.path.join(temp_dir, 'fbc_catalog') + mock_om.return_value = (fbc_dir, None) + + mock_exists.return_value = True + + mr_details = None + last_commit_sha = 'commit_sha' + mock_gccmop.return_value = (mr_details, last_commit_sha) + + image_url = 'quay.io/konflux/image@sha256:built' + mock_mpaei.return_value = image_url + + # Mock replicate_image_to_tagged_destinations to return empty list + mock_ritd.return_value = [] + + # Test + with pytest.raises(IIBError, match='list index out of range'): + build_containerized_merge.handle_containerized_merge_request( + source_from_index=source_from_index, + deprecation_list=[], + request_id=request_id, + target_index=target_index, + index_to_gitlab_push_map={'quay.io/namespace/source-index': index_git_repo}, + ) + + # Verify cleanup was called + mock_cof.assert_called_once() + + +@pytest.mark.parametrize( + 'build_tags, expected_tag_count', + [ + (None, 1), # Only request_id + (['latest'], 2), # request_id + latest + (['latest', 'v4.14'], 3), # request_id + latest + v4.14 + ], +) +@mock.patch('iib.workers.tasks.build_containerized_merge.reset_docker_config') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_on_failure') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_merge_request_if_exists') +@mock.patch('iib.workers.tasks.build_containerized_merge.push_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_pull_spec') +@mock.patch('iib.workers.tasks.build_containerized_merge.replicate_image_to_tagged_destinations') +@mock.patch('iib.workers.tasks.build_containerized_merge.monitor_pipeline_and_extract_image') +@mock.patch('iib.workers.tasks.build_containerized_merge.git_commit_and_create_mr_or_push') +@mock.patch('iib.workers.tasks.build_containerized_merge.write_build_metadata') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_validate') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.copytree') +@mock.patch('iib.workers.tasks.build_containerized_merge.merge_catalogs_dirs') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_migrate') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_list_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.deprecate_bundles_db') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_latest_version') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_from_deprecation_list') +@mock.patch('iib.workers.tasks.build_containerized_merge._opm_registry_add') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_missing_bundles_from_target_to_source') +@mock.patch('iib.workers.tasks.build_containerized_merge.validate_bundles_in_parallel') +@mock.patch('iib.workers.tasks.build_containerized_merge._get_present_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.fetch_and_verify_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_git_repository_for_build') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_build_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.Opm') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_request_for_build') +@mock.patch('iib.workers.api_utils.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_registry_token') +@mock.patch('iib.workers.tasks.build_containerized_merge.tempfile.TemporaryDirectory') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.rename') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.rmtree') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.move') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.path.exists') +@mock.patch('builtins.set', side_effect=_mock_set_for_bundles) +def test_handle_containerized_merge_request_with_build_tags( + mock_set, + mock_exists, + mock_move, + mock_rmtree, + mock_rename, + mock_tempdir, + mock_set_registry_token, + mock_srs, + mock_srs_api, + mock_prfb, + mock_opm, + mock_uiibs, + mock_pgrfb, + mock_favida, + mock_gpb, + mock_vbip, + mock_gmbfts, + mock_ora, + mock_gbfdl, + mock_gblv, + mock_dbd, + mock_glb, + mock_om, + mock_mcd, + mock_copytree, + mock_ov, + mock_wbm, + mock_gccmop, + mock_mpaei, + mock_ritd, + mock_uiips, + mock_pida, + mock_cmrif, + mock_cof, + mock_rdc, + build_tags, + expected_tag_count, +): + """Test that build_tags parameter results in correct number of image replications.""" + request_id = 7 + source_from_index = 'quay.io/namespace/source-index:v4.14' + target_index = 'quay.io/namespace/target-index:v4.15' + + temp_dir = '/tmp/iib-7-test' + mock_tempdir.return_value.__enter__.return_value = temp_dir + + prebuild_info = { + 'arches': {'amd64'}, + 'binary_image_resolved': 'registry.io/binary@sha256:abc', + 'source_from_index_resolved': 'quay.io/namespace/source-index@sha256:def', + 'target_index_resolved': 'quay.io/namespace/target-index@sha256:ghi', + 'ocp_version': 'v4.14', + 'target_ocp_version': 'v4.15', + 'distribution_scope': 'prod', + } + mock_prfb.return_value = prebuild_info + + mock_opm.opm_version = 'v1.28.0' + + index_git_repo = 'https://gitlab.com/repo' + local_git_repo_path = os.path.join(temp_dir, 'git_repo') + localized_git_catalog_path = os.path.join(local_git_repo_path, 'catalogs') + mock_pgrfb.return_value = (index_git_repo, local_git_repo_path, localized_git_catalog_path) + + source_index_db_path = os.path.join(temp_dir, 'source_index.db') + target_index_db_path = os.path.join(temp_dir, 'target_index.db') + mock_favida.side_effect = [source_index_db_path, target_index_db_path] + + source_bundles = [ + {'bundlePath': 'bundle1@sha256:111', 'csvName': 'bundle1-1.0', 'packageName': 'bundle1'} + ] + source_bundles_pull_spec = ['bundle1@sha256:111'] + target_bundles = [] + target_bundles_pull_spec = [] + mock_gpb.side_effect = [ + (source_bundles, source_bundles_pull_spec), + (target_bundles, target_bundles_pull_spec), + ] + + mock_gmbfts.return_value = ([], []) + + mock_gbfdl.return_value = [] + bundles_in_db = [{'bundlePath': 'bundle1@sha256:111', 'packageName': 'bundle1'}] + mock_glb.return_value = bundles_in_db + + fbc_dir = os.path.join(temp_dir, 'fbc_catalog') + mock_om.return_value = (fbc_dir, None) + + mock_exists.return_value = True + + mr_details = None + last_commit_sha = 'commit_sha' + mock_gccmop.return_value = (mr_details, last_commit_sha) + + image_url = 'quay.io/konflux/image@sha256:built' + mock_mpaei.return_value = image_url + + # Mock replicate_image_to_tagged_destinations to return list with expected count + output_pull_specs = ['quay.io/iib/iib-build:7'] + mock_ritd.return_value = output_pull_specs + + original_index_db_digest = 'sha256:original123' + mock_pida.return_value = original_index_db_digest + + # Test + build_containerized_merge.handle_containerized_merge_request( + source_from_index=source_from_index, + deprecation_list=[], + request_id=request_id, + target_index=target_index, + build_tags=build_tags, + index_to_gitlab_push_map={'quay.io/namespace/source-index': index_git_repo}, + ) + + # Verify replicate_image_to_tagged_destinations was called with build_tags + mock_ritd.assert_called_once() + assert mock_ritd.call_args[1]['build_tags'] == build_tags + + +@mock.patch('iib.workers.tasks.build_containerized_merge.reset_docker_config') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_on_failure') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_merge_request_if_exists') +@mock.patch('iib.workers.tasks.build_containerized_merge.push_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_pull_spec') +@mock.patch('iib.workers.tasks.build_containerized_merge.replicate_image_to_tagged_destinations') +@mock.patch('iib.workers.tasks.build_containerized_merge.monitor_pipeline_and_extract_image') +@mock.patch('iib.workers.tasks.build_containerized_merge.git_commit_and_create_mr_or_push') +@mock.patch('iib.workers.tasks.build_containerized_merge.write_build_metadata') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_validate') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.copytree') +@mock.patch('iib.workers.tasks.build_containerized_merge.merge_catalogs_dirs') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_migrate') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_list_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.deprecate_bundles_db') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_latest_version') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_from_deprecation_list') +@mock.patch('iib.workers.tasks.build_containerized_merge._opm_registry_add') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_missing_bundles_from_target_to_source') +@mock.patch('iib.workers.tasks.build_containerized_merge.validate_bundles_in_parallel') +@mock.patch('iib.workers.tasks.build_containerized_merge._get_present_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.fetch_and_verify_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_git_repository_for_build') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_build_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.Opm') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_request_for_build') +@mock.patch('iib.workers.api_utils.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_registry_token') +@mock.patch('iib.workers.tasks.build_containerized_merge.tempfile.TemporaryDirectory') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.rename') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.rmtree') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.move') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.path.exists') +@mock.patch('builtins.set', side_effect=_mock_set_for_bundles) +def test_handle_containerized_merge_request_with_invalid_bundles( + mock_set, + mock_exists, + mock_move, + mock_rmtree, + mock_rename, + mock_tempdir, + mock_set_registry_token, + mock_srs, + mock_srs_api, + mock_prfb, + mock_opm, + mock_uiibs, + mock_pgrfb, + mock_favida, + mock_gpb, + mock_vbip, + mock_gmbfts, + mock_ora, + mock_gbfdl, + mock_gblv, + mock_dbd, + mock_glb, + mock_om, + mock_mcd, + mock_copytree, + mock_ov, + mock_wbm, + mock_gccmop, + mock_mpaei, + mock_ritd, + mock_uiips, + mock_pida, + mock_cmrif, + mock_cof, + mock_rdc, +): + """Test merge request with invalid bundles (OCP version mismatch).""" + request_id = 8 + source_from_index = 'quay.io/namespace/source-index:v4.14' + target_index = 'quay.io/namespace/target-index:v4.15' + + temp_dir = '/tmp/iib-8-test' + mock_tempdir.return_value.__enter__.return_value = temp_dir + + prebuild_info = { + 'arches': {'amd64'}, + 'binary_image_resolved': 'registry.io/binary@sha256:abc', + 'source_from_index_resolved': 'quay.io/namespace/source-index@sha256:def', + 'target_index_resolved': 'quay.io/namespace/target-index@sha256:ghi', + 'ocp_version': 'v4.14', + 'target_ocp_version': 'v4.15', + 'distribution_scope': 'prod', + } + mock_prfb.return_value = prebuild_info + + mock_opm.opm_version = 'v1.28.0' + + index_git_repo = 'https://gitlab.com/repo' + local_git_repo_path = os.path.join(temp_dir, 'git_repo') + localized_git_catalog_path = os.path.join(local_git_repo_path, 'catalogs') + mock_pgrfb.return_value = (index_git_repo, local_git_repo_path, localized_git_catalog_path) + + source_index_db_path = os.path.join(temp_dir, 'source_index.db') + target_index_db_path = os.path.join(temp_dir, 'target_index.db') + mock_favida.side_effect = [source_index_db_path, target_index_db_path] + + source_bundles = [ + {'bundlePath': 'bundle1@sha256:111', 'csvName': 'bundle1-1.0', 'packageName': 'bundle1'} + ] + source_bundles_pull_spec = ['bundle1@sha256:111'] + target_bundles = [ + {'bundlePath': 'bundle2@sha256:222', 'csvName': 'bundle2-2.0', 'packageName': 'bundle2'}, + ] + target_bundles_pull_spec = ['bundle2@sha256:222'] + mock_gpb.side_effect = [ + (source_bundles, source_bundles_pull_spec), + (target_bundles, target_bundles_pull_spec), + ] + + # Mock invalid bundles (OCP version mismatch) + invalid_bundles = [ + {'bundlePath': 'bundle2@sha256:222', 'csvName': 'bundle2-2.0', 'packageName': 'bundle2'}, + ] + mock_gmbfts.return_value = ([], invalid_bundles) + + # Invalid bundles should be added to deprecation list + mock_gbfdl.return_value = ['bundle2@sha256:222'] + mock_gblv.return_value = ['bundle2@sha256:222'] + + bundles_in_db = [ + {'bundlePath': 'bundle1@sha256:111', 'packageName': 'bundle1'}, + {'bundlePath': 'bundle2@sha256:222', 'packageName': 'bundle2'}, + ] + mock_glb.return_value = bundles_in_db + + fbc_dir = os.path.join(temp_dir, 'fbc_catalog') + mock_om.return_value = (fbc_dir, None) + + mock_exists.return_value = True + + mr_details = None + last_commit_sha = 'commit_sha' + mock_gccmop.return_value = (mr_details, last_commit_sha) + + image_url = 'quay.io/konflux/image@sha256:built' + mock_mpaei.return_value = image_url + + output_pull_specs = ['quay.io/iib/iib-build:8'] + mock_ritd.return_value = output_pull_specs + + original_index_db_digest = 'sha256:original123' + mock_pida.return_value = original_index_db_digest + + # Test + build_containerized_merge.handle_containerized_merge_request( + source_from_index=source_from_index, + deprecation_list=[], + request_id=request_id, + target_index=target_index, + index_to_gitlab_push_map={'quay.io/namespace/source-index': index_git_repo}, + ) + + # Verify invalid bundles were added to deprecation list + mock_gbfdl.assert_called_once() + # Verify deprecation was called with invalid bundles + mock_dbd.assert_called_once() + deprecation_bundles = mock_dbd.call_args[1]['bundles'] + assert 'bundle2@sha256:222' in deprecation_bundles + + +@mock.patch('iib.workers.tasks.build_containerized_merge.reset_docker_config') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_on_failure') +@mock.patch('iib.workers.tasks.build_containerized_merge.cleanup_merge_request_if_exists') +@mock.patch('iib.workers.tasks.build_containerized_merge.push_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_pull_spec') +@mock.patch('iib.workers.tasks.build_containerized_merge.replicate_image_to_tagged_destinations') +@mock.patch('iib.workers.tasks.build_containerized_merge.monitor_pipeline_and_extract_image') +@mock.patch('iib.workers.tasks.build_containerized_merge.git_commit_and_create_mr_or_push') +@mock.patch('iib.workers.tasks.build_containerized_merge.write_build_metadata') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_validate') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.copytree') +@mock.patch('iib.workers.tasks.build_containerized_merge.merge_catalogs_dirs') +@mock.patch('iib.workers.tasks.build_containerized_merge.opm_migrate') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_list_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.deprecate_bundles_db') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_latest_version') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_bundles_from_deprecation_list') +@mock.patch('iib.workers.tasks.build_containerized_merge._opm_registry_add') +@mock.patch('iib.workers.tasks.build_containerized_merge.get_missing_bundles_from_target_to_source') +@mock.patch('iib.workers.tasks.build_containerized_merge.validate_bundles_in_parallel') +@mock.patch('iib.workers.tasks.build_containerized_merge._get_present_bundles') +@mock.patch('iib.workers.tasks.build_containerized_merge.fetch_and_verify_index_db_artifact') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_git_repository_for_build') +@mock.patch('iib.workers.tasks.build_containerized_merge._update_index_image_build_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.Opm') +@mock.patch('iib.workers.tasks.build_containerized_merge.prepare_request_for_build') +@mock.patch('iib.workers.api_utils.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_request_state') +@mock.patch('iib.workers.tasks.build_containerized_merge.set_registry_token') +@mock.patch('iib.workers.tasks.build_containerized_merge.tempfile.TemporaryDirectory') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.rename') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.rmtree') +@mock.patch('iib.workers.tasks.build_containerized_merge.shutil.move') +@mock.patch('iib.workers.tasks.build_containerized_merge.os.path.exists') +@mock.patch('builtins.set', side_effect=_mock_set_for_bundles) +def test_handle_containerized_merge_request_without_target_index( + mock_set, + mock_exists, + mock_move, + mock_rmtree, + mock_rename, + mock_tempdir, + mock_set_registry_token, + mock_srs, + mock_srs_api, + mock_prfb, + mock_opm, + mock_uiibs, + mock_pgrfb, + mock_favida, + mock_gpb, + mock_vbip, + mock_gmbfts, + mock_ora, + mock_gbfdl, + mock_gblv, + mock_dbd, + mock_glb, + mock_om, + mock_mcd, + mock_copytree, + mock_ov, + mock_wbm, + mock_gccmop, + mock_mpaei, + mock_ritd, + mock_uiips, + mock_pida, + mock_cmrif, + mock_cof, + mock_rdc, +): + """Test merge request when target_index is None.""" + request_id = 10 + source_from_index = 'quay.io/namespace/source-index:v4.14' + target_index = None # No target index provided + + temp_dir = '/tmp/iib-10-test' + mock_tempdir.return_value.__enter__.return_value = temp_dir + + prebuild_info = { + 'arches': {'amd64'}, + 'binary_image_resolved': 'registry.io/binary@sha256:abc', + 'source_from_index_resolved': 'quay.io/namespace/source-index@sha256:def', + 'target_index_resolved': None, # Should be None when target_index is None + 'ocp_version': 'v4.14', + 'target_ocp_version': 'v4.14', # Should default to source version + 'distribution_scope': 'prod', + } + mock_prfb.return_value = prebuild_info + + mock_opm.opm_version = 'v1.28.0' + + index_git_repo = 'https://gitlab.com/repo' + local_git_repo_path = os.path.join(temp_dir, 'git_repo') + localized_git_catalog_path = os.path.join(local_git_repo_path, 'catalogs') + mock_pgrfb.return_value = (index_git_repo, local_git_repo_path, localized_git_catalog_path) + + # Only source index.db should be fetched when target_index is None + source_index_db_path = os.path.join(temp_dir, 'source_index.db') + mock_favida.return_value = source_index_db_path + + # Only source bundles should be retrieved + source_bundles = [ + {'bundlePath': 'bundle1@sha256:111', 'csvName': 'bundle1-1.0', 'packageName': 'bundle1'}, + {'bundlePath': 'bundle2@sha256:222', 'csvName': 'bundle2-2.0', 'packageName': 'bundle2'}, + ] + source_bundles_pull_spec = ['bundle1@sha256:111', 'bundle2@sha256:222'] + mock_gpb.return_value = (source_bundles, source_bundles_pull_spec) + + # No missing bundles since there's no target index + mock_gmbfts.return_value = ([], []) + + # Mock deprecation bundles + mock_gbfdl.return_value = ['bundle1@sha256:111'] + mock_gblv.return_value = ['bundle1@sha256:111'] + + bundles_in_db = [ + {'bundlePath': 'bundle1@sha256:111', 'packageName': 'bundle1'}, + {'bundlePath': 'bundle2@sha256:222', 'packageName': 'bundle2'}, + ] + mock_glb.return_value = bundles_in_db + + fbc_dir = os.path.join(temp_dir, 'fbc_catalog') + mock_om.return_value = (fbc_dir, None) + + mock_exists.return_value = True + + mr_details = None + last_commit_sha = 'commit_sha' + mock_gccmop.return_value = (mr_details, last_commit_sha) + + image_url = 'quay.io/konflux/image@sha256:built' + mock_mpaei.return_value = image_url + + output_pull_specs = ['quay.io/iib/iib-build:10'] + mock_ritd.return_value = output_pull_specs + + original_index_db_digest = 'sha256:original123' + mock_pida.return_value = original_index_db_digest + + # Test with target_index=None + build_containerized_merge.handle_containerized_merge_request( + source_from_index=source_from_index, + deprecation_list=['bundle1:1.0'], + request_id=request_id, + target_index=target_index, # None + index_to_gitlab_push_map={'quay.io/namespace/source-index': index_git_repo}, + ) + + # Verify only source index.db was fetched (not target) + assert mock_favida.call_count == 1 + mock_favida.assert_called_once_with(source_from_index, temp_dir) + + # Verify only source bundles were retrieved (not target) + assert mock_gpb.call_count == 1 + mock_gpb.assert_called_once_with(source_index_db_path, temp_dir) + + # Verify bundles were validated (only source bundles) + mock_vbip.assert_called_once() + call_args = mock_vbip.call_args + bundles_arg = call_args[0][0] if call_args[0] else call_args[1]['bundles'] + assert isinstance(bundles_arg, list) + assert all(isinstance(b, str) for b in bundles_arg) + # Should only contain source bundles + assert set(bundles_arg) == set(source_bundles_pull_spec) + + # Verify get_missing_bundles_from_target_to_source was called with empty target bundles + mock_gmbfts.assert_called_once() + gmbfts_call_args = mock_gmbfts.call_args + assert gmbfts_call_args[1]['target_index_bundles'] == [] + + # Verify _opm_registry_add was called with empty list (no missing bundles) + mock_ora.assert_not_called() + + # Verify deprecation was processed + mock_gbfdl.assert_called_once() + mock_gblv.assert_called_once() + mock_dbd.assert_called_once() + + # Verify final state + final_call = mock_srs.call_args_list[-1] + assert final_call[0][0] == request_id + assert final_call[0][1] == 'complete' + assert 'successfully merged' in final_call[0][2] diff --git a/tests/test_workers/test_tasks/test_containerized_utils.py b/tests/test_workers/test_tasks/test_containerized_utils.py index 38c9b1313..288d26bea 100644 --- a/tests/test_workers/test_tasks/test_containerized_utils.py +++ b/tests/test_workers/test_tasks/test_containerized_utils.py @@ -443,7 +443,7 @@ def test_cleanup_on_failure_no_restore_when_no_original_digest( @patch('iib.workers.tasks.containerized_utils.skopeo_inspect') def test_validate_bundles_in_parallel_success_single_bundle(mock_skopeo_inspect): """Test validate_bundles_in_parallel with a single bundle successfully.""" - bundles = ['quay.io/ns/bundle1:v1.0.0'] + bundles = [{"bundlePath": 'quay.io/ns/bundle1:v1.0.0'}] mock_skopeo_inspect.return_value = None result = validate_bundles_in_parallel(bundles, threads=1, wait=True) @@ -458,9 +458,9 @@ def test_validate_bundles_in_parallel_success_single_bundle(mock_skopeo_inspect) def test_validate_bundles_in_parallel_success_multiple_bundles(mock_skopeo_inspect): """Test validate_bundles_in_parallel with multiple bundles successfully.""" bundles = [ - 'quay.io/ns/bundle1:v1.0.0', - 'quay.io/ns/bundle2:v2.0.0', - 'quay.io/ns/bundle3:v3.0.0', + {"bundlePath": 'quay.io/ns/bundle1:v1.0.0'}, + {"bundlePath": 'quay.io/ns/bundle2:v2.0.0'}, + {"bundlePath": 'quay.io/ns/bundle3:v3.0.0'}, ] mock_skopeo_inspect.return_value = None @@ -490,8 +490,8 @@ def test_validate_bundles_in_parallel_empty_bundles(mock_skopeo_inspect): def test_validate_bundles_in_parallel_custom_thread_count(mock_skopeo_inspect): """Test validate_bundles_in_parallel with custom thread count.""" bundles = [ - 'quay.io/ns/bundle1:v1.0.0', - 'quay.io/ns/bundle2:v2.0.0', + {"bundlePath": 'quay.io/ns/bundle1:v1.0.0'}, + {"bundlePath": 'quay.io/ns/bundle2:v2.0.0'}, ] mock_skopeo_inspect.return_value = None @@ -504,7 +504,7 @@ def test_validate_bundles_in_parallel_custom_thread_count(mock_skopeo_inspect): @patch('iib.workers.tasks.containerized_utils.skopeo_inspect') def test_validate_bundles_in_parallel_wait_false_returns_threads(mock_skopeo_inspect): """Test validate_bundles_in_parallel with wait=False returns thread list.""" - bundles = ['quay.io/ns/bundle1:v1.0.0'] + bundles = [{"bundlePath": 'quay.io/ns/bundle1:v1.0.0'}] mock_skopeo_inspect.return_value = None result = validate_bundles_in_parallel(bundles, threads=1, wait=False) @@ -523,7 +523,7 @@ def test_validate_bundles_in_parallel_wait_false_returns_threads(mock_skopeo_ins @patch('iib.workers.tasks.containerized_utils.skopeo_inspect') def test_validate_bundles_in_parallel_failure_raises_error(mock_skopeo_inspect, mock_log): """Test validate_bundles_in_parallel raises IIBError when bundle validation fails.""" - bundles = ['quay.io/ns/bundle1:v1.0.0'] + bundles = [{"bundlePath": 'quay.io/ns/bundle1:v1.0.0'}] error = IIBError('Bundle not found') mock_skopeo_inspect.side_effect = error @@ -539,11 +539,11 @@ def test_validate_bundles_in_parallel_failure_raises_error(mock_skopeo_inspect, def test_validate_bundles_in_parallel_more_bundles_than_threads(mock_skopeo_inspect): """Test validate_bundles_in_parallel with more bundles than threads.""" bundles = [ - 'quay.io/ns/bundle1:v1.0.0', - 'quay.io/ns/bundle2:v2.0.0', - 'quay.io/ns/bundle3:v3.0.0', - 'quay.io/ns/bundle4:v4.0.0', - 'quay.io/ns/bundle5:v5.0.0', + {"bundlePath": 'quay.io/ns/bundle1:v1.0.0'}, + {"bundlePath": 'quay.io/ns/bundle2:v2.0.0'}, + {"bundlePath": 'quay.io/ns/bundle3:v3.0.0'}, + {"bundlePath": 'quay.io/ns/bundle4:v4.0.0'}, + {"bundlePath": 'quay.io/ns/bundle5:v5.0.0'}, ] mock_skopeo_inspect.return_value = None @@ -556,7 +556,7 @@ def test_validate_bundles_in_parallel_more_bundles_than_threads(mock_skopeo_insp @patch('iib.workers.tasks.containerized_utils.skopeo_inspect') def test_validate_bundles_in_parallel_default_parameters(mock_skopeo_inspect): """Test validate_bundles_in_parallel with default parameters.""" - bundles = ['quay.io/ns/bundle1:v1.0.0'] + bundles = [{"bundlePath": 'quay.io/ns/bundle1:v1.0.0'}] mock_skopeo_inspect.return_value = None result = validate_bundles_in_parallel(bundles) @@ -571,8 +571,8 @@ def test_validate_bundles_in_parallel_default_parameters(mock_skopeo_inspect): def test_validate_bundles_in_parallel_multiple_threads_processing_queue(mock_skopeo_inspect): """Test that multiple threads properly process bundles from the queue.""" bundles = [ - 'quay.io/ns/bundle1:v1.0.0', - 'quay.io/ns/bundle2:v2.0.0', + {"bundlePath": 'quay.io/ns/bundle1:v1.0.0'}, + {"bundlePath": 'quay.io/ns/bundle2:v2.0.0'}, ] mock_skopeo_inspect.return_value = None @@ -594,8 +594,8 @@ def test_validate_bundles_in_parallel_one_bundle_fails_others_succeed( ): """Test that when one bundle fails, the error is logged and raised.""" bundles = [ - 'quay.io/ns/bundle1:v1.0.0', - 'quay.io/ns/bundle2:v2.0.0', + {"bundlePath": 'quay.io/ns/bundle1:v1.0.0'}, + {"bundlePath": 'quay.io/ns/bundle2:v2.0.0'}, ] # First bundle succeeds, second fails mock_skopeo_inspect.side_effect = [None, IIBError('Bundle not found')] @@ -615,7 +615,7 @@ def test_wait_for_bundle_validation_threads_success(mock_skopeo_inspect): import queue bundles_queue = queue.Queue() - bundles_queue.put('quay.io/ns/bundle1:v1.0.0') + bundles_queue.put({"bundlePath": 'quay.io/ns/bundle1:v1.0.0'}) mock_skopeo_inspect.return_value = None thread = ValidateBundlesThread(bundles_queue) @@ -638,7 +638,7 @@ def test_wait_for_bundle_validation_threads_failure_raises_error(mock_skopeo_ins import queue bundles_queue = queue.Queue() - bundles_queue.put('quay.io/ns/bundle1:v1.0.0') + bundles_queue.put({"bundlePath": 'quay.io/ns/bundle1:v1.0.0'}) error = IIBError('Bundle not found') mock_skopeo_inspect.side_effect = error @@ -650,7 +650,7 @@ def test_wait_for_bundle_validation_threads_failure_raises_error(mock_skopeo_ins assert mock_skopeo_inspect.called assert thread.exception == error - assert thread.bundle == 'quay.io/ns/bundle1:v1.0.0' + assert thread.bundle == {"bundlePath": 'quay.io/ns/bundle1:v1.0.0'} mock_log.error.assert_called() @@ -664,9 +664,9 @@ def test_wait_for_bundle_validation_threads_multiple_threads_one_fails( import queue bundles_queue1 = queue.Queue() - bundles_queue1.put('quay.io/ns/bundle1:v1.0.0') + bundles_queue1.put({"bundlePath": 'quay.io/ns/bundle1:v1.0.0'}) bundles_queue2 = queue.Queue() - bundles_queue2.put('quay.io/ns/bundle2:v2.0.0') + bundles_queue2.put({"bundlePath": 'quay.io/ns/bundle2:v2.0.0'}) mock_skopeo_inspect.side_effect = [None, IIBError('Bundle not found')] @@ -688,8 +688,8 @@ def test_wait_for_bundle_validation_threads_multiple_threads_one_fails( def test_validate_bundles_in_parallel_wait_false_then_wait_manually(mock_skopeo_inspect): """Test validate_bundles_in_parallel with wait=False and then manually waiting.""" bundles = [ - 'quay.io/ns/bundle1:v1.0.0', - 'quay.io/ns/bundle2:v2.0.0', + {"bundlePath": 'quay.io/ns/bundle1:v1.0.0'}, + {"bundlePath": 'quay.io/ns/bundle2:v2.0.0'}, ] mock_skopeo_inspect.return_value = None @@ -716,8 +716,8 @@ def test_validate_bundles_in_parallel_wait_false_then_wait_manually_with_failure ): """Test validate_bundles_in_parallel with wait=False, then manually waiting when one fails.""" bundles = [ - 'quay.io/ns/bundle1:v1.0.0', - 'quay.io/ns/bundle2:v2.0.0', + {"bundlePath": 'quay.io/ns/bundle1:v1.0.0'}, + {"bundlePath": 'quay.io/ns/bundle2:v2.0.0'}, ] mock_skopeo_inspect.side_effect = [None, IIBError('Bundle not found')] @@ -751,7 +751,7 @@ def test_wait_for_bundle_validation_threads_unknown_bundle_on_error(mock_skopeo_ bundles_queue = queue.Queue() # Add a bundle to the queue so the thread will process it - bundles_queue.put('quay.io/ns/bundle1:v1.0.0') + bundles_queue.put({"bundlePath": 'quay.io/ns/bundle1:v1.0.0'}) error = IIBError('Bundle not found') mock_skopeo_inspect.side_effect = error @@ -768,3 +768,195 @@ def test_wait_for_bundle_validation_threads_unknown_bundle_on_error(mock_skopeo_ assert mock_skopeo_inspect.called assert thread.exception == error mock_log.error.assert_called() + + +# Tests for List[str] format (pullspec strings) +@patch('iib.workers.tasks.containerized_utils.skopeo_inspect') +def test_validate_bundles_in_parallel_success_single_bundle_string(mock_skopeo_inspect): + """Test validate_bundles_in_parallel with a single bundle string successfully.""" + bundles = ['quay.io/ns/bundle1:v1.0.0'] + mock_skopeo_inspect.return_value = None + + result = validate_bundles_in_parallel(bundles, threads=1, wait=True) + + assert result is None + mock_skopeo_inspect.assert_called_once_with( + 'docker://quay.io/ns/bundle1:v1.0.0', '--raw', return_json=False + ) + + +@patch('iib.workers.tasks.containerized_utils.skopeo_inspect') +def test_validate_bundles_in_parallel_success_multiple_bundles_string(mock_skopeo_inspect): + """Test validate_bundles_in_parallel with multiple bundle strings successfully.""" + bundles = [ + 'quay.io/ns/bundle1:v1.0.0', + 'quay.io/ns/bundle2:v2.0.0', + 'quay.io/ns/bundle3:v3.0.0', + ] + mock_skopeo_inspect.return_value = None + + result = validate_bundles_in_parallel(bundles, threads=3, wait=True) + + assert result is None + assert mock_skopeo_inspect.call_count == 3 + + # Check that all bundles were validated (order may vary due to threading) + actual_calls = [call[0] for call in mock_skopeo_inspect.call_args_list] + assert len(actual_calls) == 3 + assert all('docker://quay.io/ns/bundle' in str(call[0]) for call in actual_calls) + + +@patch('iib.workers.tasks.containerized_utils.skopeo_inspect') +def test_validate_bundles_in_parallel_custom_thread_count_string(mock_skopeo_inspect): + """Test validate_bundles_in_parallel with custom thread count using string bundles.""" + bundles = [ + 'quay.io/ns/bundle1:v1.0.0', + 'quay.io/ns/bundle2:v2.0.0', + ] + mock_skopeo_inspect.return_value = None + + result = validate_bundles_in_parallel(bundles, threads=2, wait=True) + + assert result is None + assert mock_skopeo_inspect.call_count == 2 + + +@patch('iib.workers.tasks.containerized_utils.skopeo_inspect') +def test_validate_bundles_in_parallel_wait_false_returns_threads_string(mock_skopeo_inspect): + """Test validate_bundles_in_parallel with wait=False returns thread list for string bundles.""" + bundles = ['quay.io/ns/bundle1:v1.0.0'] + mock_skopeo_inspect.return_value = None + + result = validate_bundles_in_parallel(bundles, threads=1, wait=False) + + assert result is not None + assert len(result) == 1 + assert hasattr(result[0], 'join') + # Wait for thread to complete to verify it worked + result[0].join() + mock_skopeo_inspect.assert_called_once_with( + 'docker://quay.io/ns/bundle1:v1.0.0', '--raw', return_json=False + ) + + +@patch('iib.workers.tasks.containerized_utils.log') +@patch('iib.workers.tasks.containerized_utils.skopeo_inspect') +def test_validate_bundles_in_parallel_failure_raises_error_string(mock_skopeo_inspect, mock_log): + """Test validate_bundles_in_parallel raises IIBError when bundle string validation fails.""" + bundles = ['quay.io/ns/bundle1:v1.0.0'] + error = IIBError('Bundle not found') + mock_skopeo_inspect.side_effect = error + + with pytest.raises(IIBError, match='Error validating bundle'): + validate_bundles_in_parallel(bundles, threads=1, wait=True) + + assert mock_skopeo_inspect.called + # Error should be logged in the thread + assert mock_log.error.called + + +@patch('iib.workers.tasks.containerized_utils.skopeo_inspect') +def test_validate_bundles_in_parallel_more_bundles_than_threads_string(mock_skopeo_inspect): + """Test validate_bundles_in_parallel with more bundle strings than threads.""" + bundles = [ + 'quay.io/ns/bundle1:v1.0.0', + 'quay.io/ns/bundle2:v2.0.0', + 'quay.io/ns/bundle3:v3.0.0', + 'quay.io/ns/bundle4:v4.0.0', + 'quay.io/ns/bundle5:v5.0.0', + ] + mock_skopeo_inspect.return_value = None + + result = validate_bundles_in_parallel(bundles, threads=2, wait=True) + + assert result is None + assert mock_skopeo_inspect.call_count == 5 + + +@patch('iib.workers.tasks.containerized_utils.skopeo_inspect') +def test_validate_bundles_in_parallel_multiple_threads_processing_queue_string(mock_skopeo_inspect): + """Test that multiple threads properly process bundle strings from the queue.""" + bundles = [ + 'quay.io/ns/bundle1:v1.0.0', + 'quay.io/ns/bundle2:v2.0.0', + ] + mock_skopeo_inspect.return_value = None + + result = validate_bundles_in_parallel(bundles, threads=2, wait=True) + + assert result is None + # Both bundles should be validated + assert mock_skopeo_inspect.call_count == 2 + # Verify all bundles were processed + call_args = [call[0][0] for call in mock_skopeo_inspect.call_args_list] + assert 'docker://quay.io/ns/bundle1:v1.0.0' in call_args + assert 'docker://quay.io/ns/bundle2:v2.0.0' in call_args + + +@patch('iib.workers.tasks.containerized_utils.log') +@patch('iib.workers.tasks.containerized_utils.skopeo_inspect') +def test_validate_bundles_in_parallel_one_bundle_fails_others_succeed_string( + mock_skopeo_inspect, mock_log +): + """Test that when one bundle string fails, the error is logged and raised.""" + bundles = [ + 'quay.io/ns/bundle1:v1.0.0', + 'quay.io/ns/bundle2:v2.0.0', + ] + # First bundle succeeds, second fails + mock_skopeo_inspect.side_effect = [None, IIBError('Bundle not found')] + + with pytest.raises(IIBError, match='Error validating bundle'): + validate_bundles_in_parallel(bundles, threads=2, wait=True) + + assert mock_skopeo_inspect.call_count >= 1 + # Error should be logged in the thread + assert mock_log.error.called + + +@patch('iib.workers.tasks.containerized_utils.skopeo_inspect') +def test_wait_for_bundle_validation_threads_success_string(mock_skopeo_inspect): + """Test wait_for_bundle_validation_threads with successful validation for string bundles.""" + from iib.workers.tasks.containerized_utils import ValidateBundlesThread + import queue + + bundles_queue = queue.Queue() + bundles_queue.put('quay.io/ns/bundle1:v1.0.0') + mock_skopeo_inspect.return_value = None + + thread = ValidateBundlesThread(bundles_queue) + thread.start() + + # Wait for the thread using the function + wait_for_bundle_validation_threads([thread]) + + mock_skopeo_inspect.assert_called_once_with( + 'docker://quay.io/ns/bundle1:v1.0.0', '--raw', return_json=False + ) + assert thread.exception is None + + +@patch('iib.workers.tasks.containerized_utils.log') +@patch('iib.workers.tasks.containerized_utils.skopeo_inspect') +def test_wait_for_bundle_validation_threads_failure_raises_error_string( + mock_skopeo_inspect, mock_log +): + """Ensure it raises IIBError when string bundle validation fails.""" + from iib.workers.tasks.containerized_utils import ValidateBundlesThread + import queue + + bundles_queue = queue.Queue() + bundles_queue.put('quay.io/ns/bundle1:v1.0.0') + error = IIBError('Bundle not found') + mock_skopeo_inspect.side_effect = error + + thread = ValidateBundlesThread(bundles_queue) + thread.start() + + with pytest.raises(IIBError, match='Error validating bundle quay.io/ns/bundle1:v1.0.0'): + wait_for_bundle_validation_threads([thread]) + + assert mock_skopeo_inspect.called + assert thread.exception == error + assert thread.bundle == 'quay.io/ns/bundle1:v1.0.0' + mock_log.error.assert_called() diff --git a/tests/test_workers/test_tasks/test_oras_utils.py b/tests/test_workers/test_tasks/test_oras_utils.py index 789ab0d82..70d31af21 100644 --- a/tests/test_workers/test_tasks/test_oras_utils.py +++ b/tests/test_workers/test_tasks/test_oras_utils.py @@ -570,19 +570,33 @@ def test_get_artifact_combined_tag(image_name, tag, expected_tag): ), ], ) -def test_get_indexdb_artifact_pullspec(from_index, expected_pullspec): +@mock.patch('iib.workers.tasks.oras_utils.get_worker_config') +def test_get_indexdb_artifact_pullspec(mock_gwc, from_index, expected_pullspec): """Test constructing index DB artifact pullspecs.""" from iib.workers.tasks.oras_utils import get_indexdb_artifact_pullspec + mock_gwc.return_value = { + 'iib_index_db_artifact_registry': 'test-artifact-registry', + 'iib_index_db_artifact_template': '{registry}/index-db:{tag}', + 'iib_index_db_artifact_tag_template': '{image_name}-{tag}', + } + result = get_indexdb_artifact_pullspec(from_index) assert result == expected_pullspec -def test_get_indexdb_artifact_pullspec_invalid(): +@mock.patch('iib.workers.tasks.oras_utils.get_worker_config') +def test_get_indexdb_artifact_pullspec_invalid(mock_gwc): """Test _get_indexdb_artifact_pullspec with invalid pullspec.""" from iib.workers.tasks.oras_utils import get_indexdb_artifact_pullspec + mock_gwc.return_value = { + 'iib_index_db_artifact_registry': 'test-artifact-registry', + 'iib_index_db_artifact_template': '{registry}/index-db:{tag}', + 'iib_index_db_artifact_tag_template': '{image_name}-{tag}', + } + with pytest.raises(IIBError, match="Missing tag"): get_indexdb_artifact_pullspec("registry.example.com/namespace/image") diff --git a/tests/test_workers/test_tasks/test_utils.py b/tests/test_workers/test_tasks/test_utils.py index 2ccf0a3f9..62c0b8897 100644 --- a/tests/test_workers/test_tasks/test_utils.py +++ b/tests/test_workers/test_tasks/test_utils.py @@ -1628,19 +1628,33 @@ def test_change_dir_invalid_directory_does_not_change_cwd(tmp_path): ), ], ) -def test_get_indexdb_artifact_pullspec(from_index, expected_pullspec): +@mock.patch('iib.workers.tasks.oras_utils.get_worker_config') +def test_get_indexdb_artifact_pullspec(mock_gwc, from_index, expected_pullspec): """Test constructing index DB artifact pullspecs.""" from iib.workers.tasks.oras_utils import get_indexdb_artifact_pullspec + mock_gwc.return_value = { + 'iib_index_db_artifact_registry': 'test-artifact-registry', + 'iib_index_db_artifact_template': '{registry}/index-db:{tag}', + 'iib_index_db_artifact_tag_template': '{image_name}-{tag}', + } + result = get_indexdb_artifact_pullspec(from_index) assert result == expected_pullspec -def test_get_indexdb_artifact_pullspec_invalid(): +@mock.patch('iib.workers.tasks.oras_utils.get_worker_config') +def test_get_indexdb_artifact_pullspec_invalid(mock_gwc): """Test _get_indexdb_artifact_pullspec with invalid pullspec.""" from iib.workers.tasks.oras_utils import get_indexdb_artifact_pullspec + mock_gwc.return_value = { + 'iib_index_db_artifact_registry': 'test-artifact-registry', + 'iib_index_db_artifact_template': '{registry}/index-db:{tag}', + 'iib_index_db_artifact_tag_template': '{image_name}-{tag}', + } + with pytest.raises(IIBError, match="Missing tag"): get_indexdb_artifact_pullspec("registry.example.com/namespace/image") @@ -1666,15 +1680,29 @@ def test_get_indexdb_artifact_pullspec_invalid(): ), ], ) -def test_get_imagestream_artifact_pullspec(from_index, expected_pullspec): +@mock.patch('iib.workers.tasks.oras_utils.get_worker_config') +def test_get_imagestream_artifact_pullspec(mock_gwc, from_index, expected_pullspec): """Test constructing ImageStream artifact pullspecs.""" + mock_gwc.return_value = { + 'iib_index_db_imagestream_registry': 'test-imagestream-registry', + 'iib_index_db_artifact_template': '{registry}/index-db:{tag}', + 'iib_index_db_artifact_tag_template': '{image_name}-{tag}', + } + result = get_imagestream_artifact_pullspec(from_index) assert result == expected_pullspec -def test_get_imagestream_artifact_pullspec_invalid(): +@mock.patch('iib.workers.tasks.oras_utils.get_worker_config') +def test_get_imagestream_artifact_pullspec_invalid(mock_gwc): """Test get_imagestream_artifact_pullspec with invalid pullspec.""" + mock_gwc.return_value = { + 'iib_index_db_imagestream_registry': 'test-imagestream-registry', + 'iib_index_db_artifact_template': '{registry}/index-db:{tag}', + 'iib_index_db_artifact_tag_template': '{image_name}-{tag}', + } + with pytest.raises(IIBError, match="Missing tag"): get_imagestream_artifact_pullspec("registry.example.com/namespace/image")