From 23a0db502eca39c5897e5ef63473a51cf56fe72a Mon Sep 17 00:00:00 2001 From: Ashwin Das Date: Sun, 2 Aug 2026 13:20:56 -0400 Subject: [PATCH 1/3] ART-21775: add doozer beta:release-payload:rebase-and-build command Adds a new doozer CLI command that generates release payload manifests via `oc adm release new --to-dir`, writes a Dockerfile layering those manifests onto the cluster-version-operator image, pushes the result to openshift-priv/ocp-release-payloads, and triggers a Konflux build of the release payload image. Reuses BuildRepo for git operations and KonfluxClient for Application/Component/PipelineRun management. Co-authored-by: Cursor rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED --- doozer/doozerlib/cli/__main__.py | 1 + doozer/doozerlib/cli/release_payload.py | 566 +++++++++++++++++++++++ doozer/doozerlib/constants.py | 3 + doozer/tests/cli/test_release_payload.py | 513 ++++++++++++++++++++ 4 files changed, 1083 insertions(+) create mode 100644 doozer/doozerlib/cli/release_payload.py create mode 100644 doozer/tests/cli/test_release_payload.py diff --git a/doozer/doozerlib/cli/__main__.py b/doozer/doozerlib/cli/__main__.py index 033b70d485..1054834b6b 100644 --- a/doozer/doozerlib/cli/__main__.py +++ b/doozer/doozerlib/cli/__main__.py @@ -60,6 +60,7 @@ from doozerlib.cli.release_gen_assembly import gen_assembly_from_releases, releases_gen_assembly from doozerlib.cli.release_gen_assembly_targeted import gen_assembly_from_targeted from doozerlib.cli.release_gen_payload import release_gen_payload +from doozerlib.cli.release_payload import release_payload_rebase_and_build from doozerlib.cli.rpms import ( rpms_build, rpms_clone, diff --git a/doozer/doozerlib/cli/release_payload.py b/doozer/doozerlib/cli/release_payload.py new file mode 100644 index 0000000000..2355b762b0 --- /dev/null +++ b/doozer/doozerlib/cli/release_payload.py @@ -0,0 +1,566 @@ +import asyncio +import json +import logging +import os +import shutil +import sys +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Tuple + +import click +import yaml +from artcommonlib import exectools +from artcommonlib.constants import KONFLUX_DEFAULT_NAMESPACE +from artcommonlib.konflux.konflux_build_record import KonfluxBuildOutcome, KonfluxBuildRecord + +from doozerlib import constants, release_inspector +from doozerlib.backend.build_repo import BuildRepo +from doozerlib.backend.konflux_client import ImageBuildParams, KonfluxClient +from doozerlib.backend.rebaser import KonfluxRebaser +from doozerlib.cli import cli, click_coroutine, pass_runtime, validate_semver_major_minor_patch +from doozerlib.cli.release_gen_payload import ( + assembly_imagestream_base_name_generic, + default_imagestream_namespace_base_name, + payload_imagestream_namespace_and_name, +) +from doozerlib.exceptions import DoozerFatalError +from doozerlib.runtime import Runtime + +LOGGER = logging.getLogger(__name__) + +# The name of the manifest in the output of `oc adm release new --to-dir` that describes +# the images referenced by the release payload, including the cluster-version-operator tag. +IMAGE_REFERENCES_FILENAME = "image-references" +RELEASE_MANIFESTS_SUBDIR = "release-manifests" + +# All release payload builds, across every group, share a single Konflux Application. +# Each group gets its own Component underneath it (see get_component_name()). +KONFLUX_RELEASE_PAYLOAD_APPLICATION_NAME = "release-payloads" + + +class ReleasePayloadRebaseAndBuildCli: + """Implements ART-21775: generate release payload manifests, push them to + openshift-priv/ocp-release-payloads, and build a Konflux release payload image. + + Unlike a normal doozer image, the release payload has no upstream source repository: + its "rebase" step runs `oc adm release new --to-dir` to snapshot the manifests already + populated in the group's imagestream (by build-sync) and writes a minimal Dockerfile + that layers those manifests onto the cluster-version-operator image. Because Konflux + builds a single multi-arch image (a manifest list) from one Dockerfile, this command + is invoked once per group/assembly rather than once per architecture. + """ + + def __init__( + self, + runtime: Runtime, + version: str, + release: str, + arch: str, + payload_repo: str, + image_repo: str, + konflux_kubeconfig: Optional[str], + konflux_context: Optional[str], + konflux_namespace: str, + from_release: Optional[str] = None, + commit_message: Optional[str] = None, + registry_config: Optional[str] = None, + skip_checks: bool = False, + skip_tasks: Sequence[str] = (), + plr_template: str = constants.KONFLUX_DEFAULT_IMAGE_BUILD_PLR_TEMPLATE_URL, + push: bool = False, + dry_run: bool = False, + ): + self.runtime = runtime + self.version = version + self.release = release + self.arch = arch + self.payload_repo = payload_repo or constants.ART_RELEASE_PAYLOAD_GIT_REPO + self.image_repo = image_repo + self.konflux_kubeconfig = konflux_kubeconfig + self.konflux_context = konflux_context + self.konflux_namespace = konflux_namespace + self.from_release = from_release + self.commit_message = commit_message + self.registry_config = registry_config + self.skip_checks = skip_checks + self.skip_tasks = tuple(skip_tasks) + self.plr_template = plr_template + self.push = push + self.dry_run = dry_run + self._logger = LOGGER + + @staticmethod + def get_application_name() -> str: + """Konflux Application name shared by release payload builds for every group. + + e.g. `release-payloads`. + """ + return KONFLUX_RELEASE_PAYLOAD_APPLICATION_NAME + + @staticmethod + def get_component_name(group: str) -> str: + """Konflux Component name for a group's release payload builds. + + There is a single Component per group underneath the shared `release-payloads` + Application (Konflux builds all architectures as one multi-arch manifest list from + a single PipelineRun per group/assembly), e.g. `release-payload-openshift-4-21`. + """ + return f"release-payload-{group}".replace(".", "-").replace("_", "-") + + def _resolve_imagestream(self) -> Tuple[str, str]: + """Derive the (namespace, name) of the build-sync imagestream to source manifests from. + + This mirrors the naming used by build-sync, e.g. for `--group openshift-5.0 + --assembly ec.5` this resolves to `5.0-art-assembly-ec.5` in namespace `ocp`. + """ + runtime = self.runtime + version = runtime.get_minor_version() + base_name = assembly_imagestream_base_name_generic( + version, runtime.assembly, runtime.assembly_type, build_system='konflux' + ) + base_namespace = default_imagestream_namespace_base_name() + namespace, name = payload_imagestream_namespace_and_name(base_namespace, base_name, self.arch, private=False) + return namespace, name + + async def _generate_manifests(self, manifests_dir: Path) -> str: + """Run `oc adm release new --to-dir` and return the cluster-version-operator pullspec. + + :param manifests_dir: Directory to write the release manifests into. + :return: The pullspec for the cluster-version-operator image referenced by the manifests. + """ + await exectools.to_thread(manifests_dir.mkdir, parents=True, exist_ok=True) + semver = self.version.lstrip("v") + release_name = f"{semver}-{self.release}" + cmd = [ + "oc", + "adm", + "release", + "new", + f"--name={release_name}", + f"--to-dir={manifests_dir}", + ] + if self.from_release: + cmd.append(f"--from-release={self.from_release}") + else: + namespace, imagestream_name = self._resolve_imagestream() + cmd.extend(["-n", namespace, f"--from-image-stream={imagestream_name}", "--reference-mode=source"]) + if self.registry_config: + cmd.append(f"--registry-config={self.registry_config}") + + self._logger.info("Generating release manifests: %s", " ".join(cmd)) + env = os.environ.copy() + env["GOTRACEBACK"] = "all" + await exectools.cmd_assert_async(cmd, env=env) + + image_references_path = manifests_dir / IMAGE_REFERENCES_FILENAME + if not image_references_path.is_file(): + raise DoozerFatalError( + f"{IMAGE_REFERENCES_FILENAME} manifest not found at {image_references_path} " + "after running `oc adm release new`" + ) + content = await exectools.to_thread(image_references_path.read_text) + image_references = yaml.safe_load(content) + tags = (image_references or {}).get("spec", {}).get("tags") or [] + cvo_tag = next((tag for tag in tags if tag.get("name") == "cluster-version-operator"), None) + if not cvo_tag: + raise DoozerFatalError( + f"cluster-version-operator tag not found in generated {IMAGE_REFERENCES_FILENAME} manifest" + ) + cvo_pullspec = cvo_tag.get("from", {}).get("name") + if not cvo_pullspec: + raise DoozerFatalError( + f"cluster-version-operator tag has no pullspec in generated {IMAGE_REFERENCES_FILENAME} manifest" + ) + self._logger.info("Resolved cluster-version-operator pullspec: %s", cvo_pullspec) + return cvo_pullspec + + async def _resolve_art_images_pullspec(self, imagestream_pullspec: str) -> str: + """Resolve a quay art-dev pullspec from the imagestream to the original Konflux build output. + + The imagestream contains single-arch pullspecs mirrored by build-sync. The Konflux build + needs the multi-arch manifest list from art-images. We extract the NVR from the image + labels, query the Konflux DB by NVR, and return the build record's image_pullspec. + + :param imagestream_pullspec: The pullspec from the imagestream (e.g., quay.io/openshift-release-dev/...) + :return: The art-images pullspec for the Konflux build output. + """ + if not self.runtime.konflux_db: + raise DoozerFatalError("Konflux DB is not available; cannot resolve CVO pullspec to art-images pullspec") + + self.runtime.konflux_db.bind(KonfluxBuildRecord) + + name, version, release_str = await release_inspector.extract_nvr_from_pullspec( + imagestream_pullspec, registry_config=self.registry_config + ) + cvo_nvr = f"{name}-{version}-{release_str}" + self._logger.info("CVO NVR resolved from imagestream pullspec: %s", cvo_nvr) + + build_record = await self.runtime.konflux_db.get_build_record_by_nvr( + nvr=cvo_nvr, + outcome=KonfluxBuildOutcome.SUCCESS, + exclude_large_columns=True, + ) + if not build_record: + raise DoozerFatalError(f"No successful Konflux build record found for CVO NVR {cvo_nvr}") + if not build_record.image_pullspec: + raise DoozerFatalError(f"Konflux build record for CVO NVR {cvo_nvr} has no image_pullspec") + + self._logger.info("Resolved CVO to art-images pullspec: %s", build_record.image_pullspec) + return build_record.image_pullspec + + async def _rebase(self) -> Tuple[BuildRepo, str, str]: + """Generate manifests, write the Dockerfile, and commit the result to a local clone. + + :return: A tuple of (build_repo, cvo_pullspec, branch). + """ + runtime = self.runtime + repo_dir = Path(runtime.working_dir, constants.WORKING_SUBDIR_RELEASE_PAYLOAD_SOURCES, runtime.group) + branch = KonfluxRebaser.construct_dest_branch(runtime.group, runtime.assembly, "release-payload") + + self._logger.info("Preparing release payload source repository at %s on branch %s...", repo_dir, branch) + build_repo = BuildRepo(url=self.payload_repo, branch=branch, local_dir=repo_dir, logger=self._logger) + await build_repo.ensure_source(upcycle=runtime.upcycle, strict=False) + + # Clear out any stale content from a previous rebase before regenerating. + await build_repo.delete_all_files() + manifests_dir = repo_dir / RELEASE_MANIFESTS_SUBDIR + if manifests_dir.exists(): + await exectools.to_thread(shutil.rmtree, manifests_dir) + + cvo_pullspec = await self._generate_manifests(manifests_dir) + + # The imagestream pullspec is a single-arch quay art-dev image. Resolve it to the + # original Konflux build output (image_pullspec in art-images) so the Dockerfile FROM + # is a multi-arch manifest list natively accessible from within the Konflux build environment. + from_pullspec = await self._resolve_art_images_pullspec(cvo_pullspec) + + dockerfile_content = f"FROM {from_pullspec}\nCOPY {RELEASE_MANIFESTS_SUBDIR}/ /{RELEASE_MANIFESTS_SUBDIR}/\n" + dockerfile_path = repo_dir / "Dockerfile" + await exectools.to_thread(dockerfile_path.write_text, dockerfile_content) + + message = self.commit_message or ( + f"Rebase release payload manifests for {runtime.group} assembly {runtime.assembly}\n\n" + f"version: {self.version}\nrelease: {self.release}" + ) + await build_repo.commit(message, allow_empty=True, force=True) + return build_repo, cvo_pullspec, branch + + async def _build(self, build_repo: BuildRepo, arches: Sequence[str]) -> Dict: + """Ensure the Konflux Application/Component exist and start (and wait for) the build. + + :param build_repo: The rebased release payload source repo (must have a commit). + :param arches: The architectures Konflux should build for this release payload. + :return: A dict with the output image, PipelineRun name/URL, and build outcome. + """ + if not build_repo.commit_hash: + raise IOError("Release payload repository must have a commit to build. Did you rebase?") + + runtime = self.runtime + konflux_client = KonfluxClient.from_kubeconfig( + default_namespace=self.konflux_namespace, + config_file=self.konflux_kubeconfig, + context=self.konflux_context, + dry_run=self.dry_run, + ) + app_name = self.get_application_name() + component_name = self.get_component_name(runtime.group) + self._logger.info("Using Konflux application %s, component %s", app_name, component_name) + await konflux_client.ensure_application(name=app_name, display_name=app_name) + await konflux_client.ensure_component( + name=component_name, + application=app_name, + component_name=component_name, + image_repo=self.image_repo, + source_url=build_repo.https_url, + revision=build_repo.branch, + ) + + git_auth_secret = await konflux_client.ensure_git_auth_secret(namespace=self.konflux_namespace) + refresh_task = asyncio.create_task(konflux_client.token_refresh_loop(namespace=self.konflux_namespace)) + + output_image = f"{self.image_repo}:{self.version}-{self.release}" + # The Component (and its branch) is shared by every assembly of the group, so fold the + # assembly into the generateName prefix -- otherwise builds for different assemblies are + # indistinguishable in the Konflux UI's PipelineRun list. The group is omitted here: it's + # redundant with the assembly (e.g. group `openshift-4.21` + assembly `4.21.1`), and + # dropping it keeps the generated name shorter. + assembly_slug = str(runtime.assembly).replace(".", "-").replace("_", "-").lower() + try: + pipelinerun_info = await konflux_client.start_pipeline_run_for_image_build( + generate_name=f"release-payload-{assembly_slug}-", + namespace=self.konflux_namespace, + application_name=app_name, + component_name=component_name, + git_url=build_repo.https_url, + commit_sha=build_repo.commit_hash, + target_branch=build_repo.branch or build_repo.commit_hash, + output_image=output_image, + building_arches=arches, + git_auth_secret=git_auth_secret, + pipelinerun_template_url=self.plr_template, + build_params=ImageBuildParams( + skip_checks=self.skip_checks, + skip_tasks=self.skip_tasks, + hermetic=True, + fetch_tags=False, + ), + ) + url = konflux_client.resource_url(pipelinerun_info.to_dict()) + self._logger.info("PipelineRun %s created: %s", pipelinerun_info.name, url) + + self._logger.info("Waiting for PipelineRun %s to complete...", pipelinerun_info.name) + pipelinerun_info = await konflux_client.wait_for_pipelinerun( + pipelinerun_info.name, namespace=self.konflux_namespace + ) + succeeded_condition = pipelinerun_info.find_condition('Succeeded') + outcome = KonfluxBuildOutcome.extract_from_pipelinerun_succeeded_condition(succeeded_condition) + self._logger.info("PipelineRun %s completed with outcome %s", pipelinerun_info.name, outcome) + finally: + refresh_task.cancel() + try: + await refresh_task + except asyncio.CancelledError: + pass + try: + await konflux_client.delete_git_auth_secret(namespace=self.konflux_namespace) + await konflux_client.cleanup_stale_git_auth_secrets(namespace=self.konflux_namespace) + except Exception as e: + self._logger.warning("Failed to cleanup git-auth secrets: %s", e) + + return { + "output_image": output_image, + "pipelinerun_name": pipelinerun_info.name, + "pipelinerun_url": url, + "outcome": str(outcome), + } + + async def run(self) -> Dict: + """Rebase release payload manifests and, if --push was given, build them in Konflux.""" + runtime = self.runtime + runtime.initialize(config_only=True) + if runtime.assembly is None: + raise ValueError("Assemblies feature is disabled for this group. This is no longer supported.") + assert runtime.group_config is not None, "group_config is not loaded; Doozer bug?" + + arches: List[str] = runtime.get_global_konflux_arches() + if not arches: + raise DoozerFatalError(f"No architectures found in group config for {runtime.group}") + + self._logger.info( + "Rebasing release payload for %s assembly %s (arches: %s)...", runtime.group, runtime.assembly, arches + ) + build_repo, cvo_pullspec, branch = await self._rebase() + + result: Dict = { + "group": runtime.group, + "assembly": str(runtime.assembly), + "version": self.version, + "release": self.release, + "arch": self.arch, + "building_arches": arches, + "payload_repo": build_repo.https_url, + "branch": branch, + "commit_sha": build_repo.commit_hash, + "cvo_pullspec": cvo_pullspec, + "pushed": False, + "output_image": None, + "pipelinerun_name": None, + "pipelinerun_url": None, + "outcome": None, + } + + if not self.push: + self._logger.info( + "--push not set; skipping git push and Konflux build. Rebased content is available locally at %s", + build_repo.local_dir, + ) + return result + + if self.dry_run: + self._logger.warning("[DRY RUN] Would have pushed branch %s to %s", branch, build_repo.https_url) + else: + self._logger.info("Pushing branch %s to %s...", branch, build_repo.https_url) + await build_repo.push(force=True) + result["pushed"] = True + + build_result = await self._build(build_repo, arches) + result.update(build_result) + + if result["outcome"] != str(KonfluxBuildOutcome.SUCCESS): + raise DoozerFatalError( + f"Release payload build did not succeed for {runtime.group} assembly {runtime.assembly}: " + f"{result['outcome']} ({result.get('pipelinerun_url')})" + ) + return result + + +@cli.command( + "beta:release-payload:rebase-and-build", + short_help="Generate release payload manifests and build the release payload image in Konflux", +) +@click.option( + "--version", + metavar='VERSION', + required=True, + callback=validate_semver_major_minor_patch, + help="Version string for the release payload NVR.", +) +@click.option("--release", metavar='RELEASE', required=True, help="Release string for the release payload NVR.") +@click.option( + "--arch", + metavar='ARCH', + default='x86_64', + help="Brew arch of the build-sync imagestream to source release manifests from." + " Does not limit which arches Konflux builds; Konflux always builds a multi-arch manifest list.", +) +@click.option( + "--from-release", + metavar='PULLSPEC', + default=None, + help="Use an existing release image pullspec as the manifest source instead of the derived imagestream.", +) +@click.option( + "--message", + "-m", + metavar='MSG', + default=None, + help="Commit message. If not provided, a default generated message will be used.", +) +@click.option( + "--payload-repo", + metavar='URL', + default=constants.ART_RELEASE_PAYLOAD_GIT_REPO, + help="The git repository to push the rebased release payload source to.", +) +@click.option( + "--image-repo", + default=constants.KONFLUX_DEFAULT_IMAGE_REPO, + help="Push the built release payload image to the specified repo.", +) +@click.option( + '--konflux-kubeconfig', metavar='PATH', help='Path to the kubeconfig file to use for Konflux cluster connections.' +) +@click.option( + '--konflux-context', + metavar='CONTEXT', + help='The name of the kubeconfig context to use for Konflux cluster connections.', +) +@click.option( + '--konflux-namespace', + metavar='NAMESPACE', + default=KONFLUX_DEFAULT_NAMESPACE, + help='The namespace to use for Konflux cluster connections.', +) +@click.option( + "--registry-config", + metavar='PATH', + default=None, + help="Path to a registry auth file to use when reading operator images while generating manifests.", +) +@click.option('--skip-checks', default=False, is_flag=True, help='Skip all post build checks') +@click.option( + '--skip-task', + 'skip_tasks', + multiple=True, + help='Remove a named Tekton task from the PipelineRun. Repeatable (e.g. --skip-task clair-scan --skip-task sast-snyk-check).', +) +@click.option( + '--plr-template', + required=False, + default=constants.KONFLUX_DEFAULT_IMAGE_BUILD_PLR_TEMPLATE_URL, + help='Use a custom PipelineRun template to build the release payload image.' + ' Overrides the default template from openshift-priv/art-konflux-template', +) +@click.option( + '--push', + is_flag=True, + default=False, + help='Push the rebased content to git and trigger the Konflux build. Without it, manifests are only' + ' generated and committed locally.', +) +@click.option( + '--dry-run', + is_flag=True, + default=False, + help='Do not push to git or call the Konflux API; only log what would happen.', +) +@click.option( + '--output', + '-o', + type=click.Choice(['json'], case_sensitive=False), + default=None, + help='Output the result in the specified machine-parseable format.', +) +@pass_runtime +@click_coroutine +async def release_payload_rebase_and_build( + runtime: Runtime, + version: str, + release: str, + arch: str, + from_release: Optional[str], + message: Optional[str], + payload_repo: str, + image_repo: str, + konflux_kubeconfig: Optional[str], + konflux_context: Optional[str], + konflux_namespace: str, + registry_config: Optional[str], + skip_checks: bool, + skip_tasks: Tuple[str, ...], + plr_template: str, + push: bool, + dry_run: bool, + output: Optional[str], +): + """ + Generate release payload manifests and build the release payload image in Konflux. + + This command runs `oc adm release new --to-dir` to snapshot the manifests already present + in the group's build-sync imagestream, writes a Dockerfile that layers those manifests onto + the cluster-version-operator image, pushes the result to openshift-priv/ocp-release-payloads, + and (with --push) triggers a Konflux build of the release payload image. + + Example usage: + + doozer --group=openshift-4.21 --assembly=4.21.1 beta:release-payload:rebase-and-build \\ + --version=4.21.1 --release=202608011200.p0 --push + """ + if not konflux_kubeconfig: + konflux_kubeconfig = os.environ.get('KONFLUX_SA_KUBECONFIG') + if not konflux_kubeconfig: + LOGGER.info( + "--konflux-kubeconfig and KONFLUX_SA_KUBECONFIG env var are not set. Will rely on oc being logged in" + ) + + cli_obj = ReleasePayloadRebaseAndBuildCli( + runtime=runtime, + version=version, + release=release, + arch=arch, + payload_repo=payload_repo, + image_repo=image_repo, + konflux_kubeconfig=konflux_kubeconfig, + konflux_context=konflux_context, + konflux_namespace=konflux_namespace, + from_release=from_release, + commit_message=message, + registry_config=registry_config, + skip_checks=skip_checks, + skip_tasks=skip_tasks, + plr_template=plr_template, + push=push, + dry_run=dry_run, + ) + try: + result = await cli_obj.run() + except Exception as e: + if output == 'json': + click.echo(json.dumps({"error": str(e)}, indent=2)) + sys.exit(1) + raise + + if output == 'json': + click.echo(json.dumps(result, indent=2)) + else: + LOGGER.info("Release payload rebase and build complete:\n%s", json.dumps(result, indent=2)) diff --git a/doozer/doozerlib/constants.py b/doozer/doozerlib/constants.py index ec64374da8..c67691759a 100644 --- a/doozer/doozerlib/constants.py +++ b/doozer/doozerlib/constants.py @@ -51,6 +51,7 @@ WORKING_SUBDIR_KONFLUX_BUILD_SOURCES = "konflux_build_sources" WORKING_SUBDIR_KONFLUX_FBC_SOURCES = "konflux_fbc_sources" WORKING_SUBDIR_KONFLUX_OKD_SOURCES = "konflux_okd_sources" +WORKING_SUBDIR_RELEASE_PAYLOAD_SOURCES = "release_payload_sources" # Legacy constants removed - use get_art_prod_image_repo_for_version() from artcommonlib.util instead DELIVERY_IMAGE_REGISTRY = "registry.redhat.io" KONFLUX_UI_HOST = "https://konflux-ui.apps.kflux-ocp-p01.7ayg.p1.openshiftapps.com" @@ -58,6 +59,8 @@ KONFLUX_DEFAULT_BUNDLE_BUILD_PLR_TEMPLATE_URL = "https://api.github.com/repos/openshift-priv/art-konflux-template/contents/.tekton/art-bundle-konflux-template-push.yaml?ref=main" KONFLUX_DEFAULT_FBC_BUILD_PLR_TEMPLATE_URL = "https://api.github.com/repos/openshift-priv/art-konflux-template/contents/.tekton/art-fbc-konflux-template-push.yaml?ref=main" ART_FBC_GIT_REPO = "https://github.com/openshift-priv/art-fbc.git" +# ART-14237 / ART-21775: source repo for Konflux-built release payload images +ART_RELEASE_PAYLOAD_GIT_REPO = "https://github.com/openshift-priv/ocp-release-payloads.git" REGISTRY_PROXY_BASE_URL = "registry-proxy.engineering.redhat.com" BREW_REGISTRY_BASE_URL = "brew.registry.redhat.io" diff --git a/doozer/tests/cli/test_release_payload.py b/doozer/tests/cli/test_release_payload.py new file mode 100644 index 0000000000..751b08aa13 --- /dev/null +++ b/doozer/tests/cli/test_release_payload.py @@ -0,0 +1,513 @@ +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from artcommonlib.assembly import AssemblyTypes +from artcommonlib.konflux.konflux_build_record import KonfluxBuildOutcome, KonfluxBuildRecord +from artcommonlib.model import Model +from doozerlib import constants +from doozerlib.cli.release_payload import ReleasePayloadRebaseAndBuildCli +from doozerlib.exceptions import DoozerFatalError +from doozerlib.runtime import Runtime + +IMAGE_REFERENCES_YAML = """\ +apiVersion: image.openshift.io/1 +kind: ImageStream +spec: + tags: + - name: cluster-version-operator + from: + name: registry.example.com/ocp/release@sha256:cvo-digest + - name: cli + from: + name: registry.example.com/ocp/release@sha256:cli-digest +""" + +IMAGE_REFERENCES_NO_CVO_YAML = """\ +apiVersion: image.openshift.io/1 +kind: ImageStream +spec: + tags: + - name: cli + from: + name: registry.example.com/ocp/release@sha256:cli-digest +""" + + +def _make_cli(runtime, **overrides) -> ReleasePayloadRebaseAndBuildCli: + kwargs = dict( + runtime=runtime, + version="4.21.1", + release="202608011200.p0", + arch="x86_64", + payload_repo="https://github.com/openshift-priv/ocp-release-payloads.git", + image_repo="quay.io/openshift-release-dev/ocp-v4.0-art-dev", + konflux_kubeconfig="/path/to/kubeconfig", + konflux_context="test-context", + konflux_namespace="ocp-art-tenant", + push=False, + dry_run=False, + ) + kwargs.update(overrides) + return ReleasePayloadRebaseAndBuildCli(**kwargs) + + +class TestReleasePayloadRebaseAndBuildCliNaming(unittest.TestCase): + def test_get_application_name_is_shared_across_groups(self): + self.assertEqual(ReleasePayloadRebaseAndBuildCli.get_application_name(), "release-payloads") + + def test_get_component_name_is_group_specific(self): + self.assertEqual( + ReleasePayloadRebaseAndBuildCli.get_component_name("openshift-4.21"), + "release-payload-openshift-4-21", + ) + self.assertEqual( + ReleasePayloadRebaseAndBuildCli.get_component_name("openshift-5.0"), + "release-payload-openshift-5-0", + ) + + +class TestResolveImagestream(unittest.TestCase): + def setUp(self): + self.runtime = mock.Mock(spec=Runtime) + self.runtime.group = "openshift-4.21" + self.runtime.assembly = "stream" + self.runtime.assembly_type = AssemblyTypes.STREAM + self.runtime.group_config = Model({"vars": {"MAJOR": 4, "MINOR": 21}}) + self.runtime.get_minor_version = mock.Mock(return_value="4.21") + self.cli = _make_cli(self.runtime, arch="x86_64") + + def test_resolve_imagestream_for_stream_assembly(self): + namespace, name = self.cli._resolve_imagestream() + self.assertEqual(namespace, "ocp") + self.assertEqual(name, "4.21-art-latest") + + def test_resolve_imagestream_for_named_assembly(self): + self.runtime.assembly = "4.21.1" + self.runtime.assembly_type = AssemblyTypes.STANDARD + namespace, name = self.cli._resolve_imagestream() + self.assertEqual(namespace, "ocp") + self.assertEqual(name, "4.21-art-assembly-4.21.1") + + def test_resolve_imagestream_for_non_default_arch(self): + self.cli.arch = "s390x" + namespace, name = self.cli._resolve_imagestream() + self.assertEqual(namespace, "ocp-s390x") + self.assertEqual(name, "4.21-art-latest-s390x") + + +class TestGenerateManifests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.runtime = mock.Mock(spec=Runtime) + self.runtime.group = "openshift-4.21" + self.runtime.assembly = "stream" + self.runtime.assembly_type = AssemblyTypes.STREAM + self.runtime.group_config = Model({"vars": {"MAJOR": 4, "MINOR": 21}}) + self.runtime.get_minor_version = mock.Mock(return_value="4.21") + self.cli = _make_cli(self.runtime) + self.tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tmpdir.cleanup) + self.manifests_dir = Path(self.tmpdir.name) / "release-manifests" + + @mock.patch("doozerlib.cli.release_payload.exectools.cmd_assert_async") + async def test_generate_manifests_resolves_cvo_pullspec(self, mock_cmd_assert_async): + async def _write_manifests(cmd, **kwargs): + (self.manifests_dir / "image-references").write_text(IMAGE_REFERENCES_YAML) + return 0 + + mock_cmd_assert_async.side_effect = _write_manifests + + cvo_pullspec = await self.cli._generate_manifests(self.manifests_dir) + + self.assertEqual(cvo_pullspec, "registry.example.com/ocp/release@sha256:cvo-digest") + cmd = mock_cmd_assert_async.call_args.args[0] + self.assertIn(f"--to-dir={self.manifests_dir}", cmd) + self.assertIn("--name=4.21.1-202608011200.p0", cmd) + self.assertIn("-n", cmd) + self.assertIn("ocp", cmd) + self.assertIn("--from-image-stream=4.21-art-latest", cmd) + self.assertIn("--reference-mode=source", cmd) + + @mock.patch("doozerlib.cli.release_payload.exectools.cmd_assert_async") + async def test_generate_manifests_uses_from_release_override(self, mock_cmd_assert_async): + self.cli.from_release = "registry.example.com/ocp/release:4.21.0" + + async def _write_manifests(cmd, **kwargs): + (self.manifests_dir / "image-references").write_text(IMAGE_REFERENCES_YAML) + return 0 + + mock_cmd_assert_async.side_effect = _write_manifests + + await self.cli._generate_manifests(self.manifests_dir) + + cmd = mock_cmd_assert_async.call_args.args[0] + self.assertIn("--from-release=registry.example.com/ocp/release:4.21.0", cmd) + self.assertNotIn("--from-image-stream=4.21-konflux-art-latest", cmd) + + @mock.patch("doozerlib.cli.release_payload.exectools.cmd_assert_async") + async def test_generate_manifests_missing_image_references_raises(self, mock_cmd_assert_async): + mock_cmd_assert_async.return_value = 0 # Does not write the file + + with self.assertRaises(DoozerFatalError): + await self.cli._generate_manifests(self.manifests_dir) + + @mock.patch("doozerlib.cli.release_payload.exectools.cmd_assert_async") + async def test_generate_manifests_missing_cvo_tag_raises(self, mock_cmd_assert_async): + async def _write_manifests(cmd, **kwargs): + (self.manifests_dir / "image-references").write_text(IMAGE_REFERENCES_NO_CVO_YAML) + return 0 + + mock_cmd_assert_async.side_effect = _write_manifests + + with self.assertRaises(DoozerFatalError): + await self.cli._generate_manifests(self.manifests_dir) + + @mock.patch("doozerlib.cli.release_payload.exectools.cmd_assert_async") + async def test_generate_manifests_includes_registry_config(self, mock_cmd_assert_async): + self.cli.registry_config = "/path/to/auth.json" + + async def _write_manifests(cmd, **kwargs): + (self.manifests_dir / "image-references").write_text(IMAGE_REFERENCES_YAML) + return 0 + + mock_cmd_assert_async.side_effect = _write_manifests + + await self.cli._generate_manifests(self.manifests_dir) + + cmd = mock_cmd_assert_async.call_args.args[0] + self.assertIn("--registry-config=/path/to/auth.json", cmd) + + +class TestRebase(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tmpdir.cleanup) + + self.runtime = mock.Mock(spec=Runtime) + self.runtime.group = "openshift-4.21" + self.runtime.assembly = "4.21.1" + self.runtime.assembly_type = AssemblyTypes.STANDARD + self.runtime.upcycle = False + self.runtime.working_dir = self.tmpdir.name + self.runtime.group_config = Model({"vars": {"MAJOR": 4, "MINOR": 21}}) + + self.cli = _make_cli(self.runtime) + + # Pre-create the repo directory that BuildRepo would normally clone into, + # since BuildRepo itself is mocked out below. + self.repo_dir = Path(self.tmpdir.name, constants.WORKING_SUBDIR_RELEASE_PAYLOAD_SOURCES, self.runtime.group) + self.repo_dir.mkdir(parents=True, exist_ok=True) + + @mock.patch("doozerlib.cli.release_payload.BuildRepo") + async def test_rebase_writes_dockerfile_and_commits(self, mock_build_repo_class): + mock_build_repo = mock.AsyncMock() + mock_build_repo.local_dir = self.repo_dir + mock_build_repo.https_url = "https://github.com/openshift-priv/ocp-release-payloads.git" + mock_build_repo.branch = "art-openshift-4.21-assembly-4.21.1-dgk-release-payload" + mock_build_repo.commit_hash = "abc1234" + mock_build_repo_class.return_value = mock_build_repo + + art_images_pullspec = "quay.io/redhat-user-workloads/ocp-art-tenant/art-images:cvo-tag" + with ( + mock.patch.object( + self.cli, "_generate_manifests", mock.AsyncMock(return_value="registry.example.com/cvo@sha256:digest") + ) as mock_generate_manifests, + mock.patch.object( + self.cli, "_resolve_art_images_pullspec", mock.AsyncMock(return_value=art_images_pullspec) + ) as mock_resolve, + ): + build_repo, cvo_pullspec, branch = await self.cli._rebase() + + mock_build_repo_class.assert_called_once() + _, kwargs = mock_build_repo_class.call_args + self.assertEqual(kwargs["url"], self.cli.payload_repo) + self.assertEqual(kwargs["branch"], "art-openshift-4.21-assembly-4.21.1-dgk-release-payload") + + mock_build_repo.ensure_source.assert_awaited_once() + mock_build_repo.delete_all_files.assert_awaited_once() + mock_generate_manifests.assert_awaited_once() + mock_resolve.assert_awaited_once_with("registry.example.com/cvo@sha256:digest") + mock_build_repo.commit.assert_awaited_once() + + self.assertEqual(cvo_pullspec, "registry.example.com/cvo@sha256:digest") + self.assertEqual(branch, "art-openshift-4.21-assembly-4.21.1-dgk-release-payload") + self.assertIs(build_repo, mock_build_repo) + + dockerfile_content = (self.repo_dir / "Dockerfile").read_text() + self.assertIn(f"FROM {art_images_pullspec}", dockerfile_content) + self.assertIn("COPY release-manifests/ /release-manifests/", dockerfile_content) + + commit_message = mock_build_repo.commit.call_args.args[0] + self.assertIn("openshift-4.21", commit_message) + self.assertIn("4.21.1", commit_message) + + @mock.patch("doozerlib.cli.release_payload.BuildRepo") + async def test_rebase_uses_custom_commit_message(self, mock_build_repo_class): + mock_build_repo = mock.AsyncMock() + mock_build_repo.local_dir = self.repo_dir + mock_build_repo.https_url = "https://github.com/openshift-priv/ocp-release-payloads.git" + mock_build_repo.branch = "some-branch" + mock_build_repo.commit_hash = "abc1234" + mock_build_repo_class.return_value = mock_build_repo + + self.cli.commit_message = "Custom commit message" + with ( + mock.patch.object(self.cli, "_generate_manifests", mock.AsyncMock(return_value="registry.example.com/cvo")), + mock.patch.object( + self.cli, + "_resolve_art_images_pullspec", + mock.AsyncMock(return_value="quay.io/redhat-user-workloads/ocp-art-tenant/art-images:cvo-tag"), + ), + ): + await self.cli._rebase() + + mock_build_repo.commit.assert_awaited_once_with("Custom commit message", allow_empty=True, force=True) + + +class TestResolveArtImagesPullspec(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.runtime = mock.Mock(spec=Runtime) + self.runtime.group = "openshift-4.21" + self.runtime.assembly = "4.21.1" + self.runtime.konflux_db = mock.AsyncMock() + self.runtime.konflux_db.bind = mock.Mock() + self.cli = _make_cli(self.runtime) + + @mock.patch("doozerlib.cli.release_payload.release_inspector.extract_nvr_from_pullspec") + async def test_resolves_to_image_pullspec(self, mock_extract_nvr): + mock_extract_nvr.return_value = ("cluster-version-operator-container", "4.21.1", "202608011200.p0") + build_record = mock.Mock(spec=KonfluxBuildRecord) + build_record.image_pullspec = "quay.io/redhat-user-workloads/ocp-art-tenant/art-images:cvo-tag" + self.runtime.konflux_db.get_build_record_by_nvr = mock.AsyncMock(return_value=build_record) + + result = await self.cli._resolve_art_images_pullspec("quay.io/openshift-release-dev/ocp-v5.0-art-dev@sha256:abc") + + mock_extract_nvr.assert_awaited_once_with( + "quay.io/openshift-release-dev/ocp-v5.0-art-dev@sha256:abc", registry_config=None + ) + self.runtime.konflux_db.get_build_record_by_nvr.assert_awaited_once_with( + nvr="cluster-version-operator-container-4.21.1-202608011200.p0", + outcome=KonfluxBuildOutcome.SUCCESS, + exclude_large_columns=True, + ) + self.assertEqual(result, "quay.io/redhat-user-workloads/ocp-art-tenant/art-images:cvo-tag") + + @mock.patch("doozerlib.cli.release_payload.release_inspector.extract_nvr_from_pullspec") + async def test_raises_when_no_build_record(self, mock_extract_nvr): + mock_extract_nvr.return_value = ("cluster-version-operator-container", "4.21.1", "202608011200.p0") + self.runtime.konflux_db.get_build_record_by_nvr = mock.AsyncMock(return_value=None) + + with self.assertRaises(DoozerFatalError): + await self.cli._resolve_art_images_pullspec("quay.io/openshift-release-dev/ocp-v5.0-art-dev@sha256:abc") + + @mock.patch("doozerlib.cli.release_payload.release_inspector.extract_nvr_from_pullspec") + async def test_raises_when_build_record_has_no_pullspec(self, mock_extract_nvr): + mock_extract_nvr.return_value = ("cluster-version-operator-container", "4.21.1", "202608011200.p0") + build_record = mock.Mock(spec=KonfluxBuildRecord) + build_record.image_pullspec = "" + self.runtime.konflux_db.get_build_record_by_nvr = mock.AsyncMock(return_value=build_record) + + with self.assertRaises(DoozerFatalError): + await self.cli._resolve_art_images_pullspec("quay.io/openshift-release-dev/ocp-v5.0-art-dev@sha256:abc") + + async def test_raises_when_konflux_db_not_available(self): + self.runtime.konflux_db = None + + with self.assertRaises(DoozerFatalError): + await self.cli._resolve_art_images_pullspec("quay.io/openshift-release-dev/ocp-v5.0-art-dev@sha256:abc") + + +class TestBuild(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.runtime = mock.Mock(spec=Runtime) + self.runtime.group = "openshift-4.21" + self.runtime.assembly = "4.21.1" + self.cli = _make_cli(self.runtime) + + self.build_repo = mock.Mock() + self.build_repo.commit_hash = "abc1234" + self.build_repo.branch = "art-openshift-4.21-dgk-release-payload" + self.build_repo.https_url = "https://github.com/openshift-priv/ocp-release-payloads.git" + + def _mock_konflux_client(self, succeeded: bool = True, reason: str = "Succeeded"): + konflux_client = mock.AsyncMock() + konflux_client.ensure_application = mock.AsyncMock() + konflux_client.ensure_component = mock.AsyncMock() + + pipelinerun_info = mock.Mock() + pipelinerun_info.name = "release-payload-openshift-4-21-abc123" + pipelinerun_info.to_dict.return_value = {"metadata": {"name": pipelinerun_info.name}} + konflux_client.start_pipeline_run_for_image_build = mock.AsyncMock(return_value=pipelinerun_info) + konflux_client.resource_url = mock.Mock(return_value="https://konflux.example.com/pipelinerun/1") + + completed_pipelinerun_info = mock.Mock() + completed_pipelinerun_info.name = pipelinerun_info.name + condition = mock.Mock() + condition.type = "Succeeded" + condition.is_status_true.return_value = succeeded + condition.status = "True" if succeeded else "False" + condition.reason = reason + completed_pipelinerun_info.find_condition.return_value = condition + konflux_client.wait_for_pipelinerun = mock.AsyncMock(return_value=completed_pipelinerun_info) + return konflux_client + + @mock.patch("doozerlib.cli.release_payload.KonfluxClient") + async def test_build_success(self, mock_konflux_client_class): + konflux_client = self._mock_konflux_client(succeeded=True) + mock_konflux_client_class.from_kubeconfig.return_value = konflux_client + + result = await self.cli._build(self.build_repo, arches=["x86_64", "s390x"]) + + konflux_client.ensure_application.assert_awaited_once() + konflux_client.ensure_component.assert_awaited_once() + konflux_client.start_pipeline_run_for_image_build.assert_awaited_once() + _, kwargs = konflux_client.start_pipeline_run_for_image_build.call_args + self.assertEqual(kwargs["building_arches"], ["x86_64", "s390x"]) + self.assertEqual(kwargs["git_url"], self.build_repo.https_url) + self.assertEqual(kwargs["commit_sha"], self.build_repo.commit_hash) + self.assertEqual(kwargs["output_image"], f"{self.cli.image_repo}:{self.cli.version}-{self.cli.release}") + self.assertEqual(kwargs["generate_name"], "release-payload-4-21-1-") + + self.assertEqual(result["outcome"], str(KonfluxBuildOutcome.SUCCESS)) + self.assertEqual(result["output_image"], f"{self.cli.image_repo}:{self.cli.version}-{self.cli.release}") + self.assertEqual(result["pipelinerun_url"], "https://konflux.example.com/pipelinerun/1") + + @mock.patch("doozerlib.cli.release_payload.KonfluxClient") + async def test_build_failure_outcome(self, mock_konflux_client_class): + konflux_client = self._mock_konflux_client(succeeded=False, reason="Error") + mock_konflux_client_class.from_kubeconfig.return_value = konflux_client + + result = await self.cli._build(self.build_repo, arches=["x86_64"]) + + self.assertEqual(result["outcome"], str(KonfluxBuildOutcome.BUILD_ERROR)) + + async def test_build_raises_without_commit(self): + self.build_repo.commit_hash = None + + with self.assertRaises(IOError): + await self.cli._build(self.build_repo, arches=["x86_64"]) + + +class TestRun(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.runtime = mock.Mock(spec=Runtime) + self.runtime.group = "openshift-4.21" + self.runtime.assembly = "4.21.1" + self.runtime.initialize = mock.Mock() + self.runtime.group_config = Model({"vars": {"MAJOR": 4, "MINOR": 21}}) + self.runtime.get_global_konflux_arches = mock.Mock(return_value=["x86_64", "s390x"]) + + self.cli = _make_cli(self.runtime, push=False, dry_run=False) + + self.build_repo = mock.Mock() + self.build_repo.https_url = "https://github.com/openshift-priv/ocp-release-payloads.git" + self.build_repo.branch = "art-openshift-4.21-assembly-4.21.1-dgk-release-payload" + self.build_repo.commit_hash = "abc1234" + self.build_repo.local_dir = "/tmp/release-payload" + self.build_repo.push = mock.AsyncMock() + + async def test_run_without_push_skips_git_push_and_build(self): + with ( + mock.patch.object( + self.cli, + "_rebase", + mock.AsyncMock(return_value=(self.build_repo, "registry.example.com/cvo", self.build_repo.branch)), + ), + mock.patch.object(self.cli, "_build", mock.AsyncMock()) as mock_build, + ): + result = await self.cli.run() + + mock_build.assert_not_awaited() + self.build_repo.push.assert_not_awaited() + self.assertFalse(result["pushed"]) + self.assertIsNone(result["outcome"]) + self.assertEqual(result["building_arches"], ["x86_64", "s390x"]) + + async def test_run_raises_if_no_arches_configured(self): + self.runtime.get_global_konflux_arches.return_value = [] + + with self.assertRaises(DoozerFatalError): + await self.cli.run() + + async def test_run_raises_if_assembly_disabled(self): + self.runtime.assembly = None + + with self.assertRaises(ValueError): + await self.cli.run() + + async def test_run_with_push_pushes_and_builds(self): + self.cli.push = True + self.cli.dry_run = False + + build_result = { + "output_image": "quay.io/example/repo:4.21.1-1", + "pipelinerun_name": "release-payload-abc123", + "pipelinerun_url": "https://konflux.example.com/pipelinerun/1", + "outcome": str(KonfluxBuildOutcome.SUCCESS), + } + with ( + mock.patch.object( + self.cli, + "_rebase", + mock.AsyncMock(return_value=(self.build_repo, "registry.example.com/cvo", self.build_repo.branch)), + ), + mock.patch.object(self.cli, "_build", mock.AsyncMock(return_value=build_result)) as mock_build, + ): + result = await self.cli.run() + + self.build_repo.push.assert_awaited_once_with(force=True) + mock_build.assert_awaited_once_with(self.build_repo, ["x86_64", "s390x"]) + self.assertTrue(result["pushed"]) + self.assertEqual(result["outcome"], str(KonfluxBuildOutcome.SUCCESS)) + self.assertEqual(result["output_image"], build_result["output_image"]) + + async def test_run_with_push_and_dry_run_skips_actual_push(self): + self.cli.push = True + self.cli.dry_run = True + + build_result = { + "output_image": "quay.io/example/repo:4.21.1-1", + "pipelinerun_name": "release-payload-abc123-dry-run", + "pipelinerun_url": "https://konflux.example.com/pipelinerun/1", + "outcome": str(KonfluxBuildOutcome.SUCCESS), + } + with ( + mock.patch.object( + self.cli, + "_rebase", + mock.AsyncMock(return_value=(self.build_repo, "registry.example.com/cvo", self.build_repo.branch)), + ), + mock.patch.object(self.cli, "_build", mock.AsyncMock(return_value=build_result)) as mock_build, + ): + result = await self.cli.run() + + self.build_repo.push.assert_not_awaited() + mock_build.assert_awaited_once() + self.assertFalse(result["pushed"]) + self.assertEqual(result["outcome"], str(KonfluxBuildOutcome.SUCCESS)) + + async def test_run_raises_when_build_does_not_succeed(self): + self.cli.push = True + self.cli.dry_run = False + + build_result = { + "output_image": "quay.io/example/repo:4.21.1-1", + "pipelinerun_name": "release-payload-abc123", + "pipelinerun_url": "https://konflux.example.com/pipelinerun/1", + "outcome": str(KonfluxBuildOutcome.BUILD_ERROR), + } + with ( + mock.patch.object( + self.cli, + "_rebase", + mock.AsyncMock(return_value=(self.build_repo, "registry.example.com/cvo", self.build_repo.branch)), + ), + mock.patch.object(self.cli, "_build", mock.AsyncMock(return_value=build_result)), + ): + with self.assertRaises(DoozerFatalError): + await self.cli.run() + + +if __name__ == "__main__": + unittest.main() From 1c793da16070220f54e451137133d79b6d651e5d Mon Sep 17 00:00:00 2001 From: Ashwin Das Date: Mon, 10 Aug 2026 22:55:19 -0400 Subject: [PATCH 2/3] ART-21775: use multi-stage Dockerfile for release payload builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the format produced by `oc adm release new`: use a two-stage Dockerfile (FROM AS cvo → FROM scratch + COPY --from=cvo) and add the io.openshift.release / io.openshift.release.base-image-digest labels so the image is recognized by `oc adm release info`. Co-Authored-By: Claude Opus 4.6 rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED --- doozer/doozerlib/cli/release_payload.py | 16 ++++++++++- doozer/tests/cli/test_release_payload.py | 35 +++++++++++++++++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/doozer/doozerlib/cli/release_payload.py b/doozer/doozerlib/cli/release_payload.py index 2355b762b0..a16e2863d9 100644 --- a/doozer/doozerlib/cli/release_payload.py +++ b/doozer/doozerlib/cli/release_payload.py @@ -234,7 +234,21 @@ async def _rebase(self) -> Tuple[BuildRepo, str, str]: # is a multi-arch manifest list natively accessible from within the Konflux build environment. from_pullspec = await self._resolve_art_images_pullspec(cvo_pullspec) - dockerfile_content = f"FROM {from_pullspec}\nCOPY {RELEASE_MANIFESTS_SUBDIR}/ /{RELEASE_MANIFESTS_SUBDIR}/\n" + release_name = f"{self.version.lstrip('v')}-{self.release}" + if "@" not in from_pullspec: + raise DoozerFatalError( + f"Expected digest-based art-images pullspec but got: {from_pullspec}" + ) + cvo_image_digest = from_pullspec.split("@", 1)[1] + dockerfile_content = ( + f"FROM {from_pullspec} AS cvo\n" + f"\n" + f"FROM scratch\n" + f"COPY --from=cvo / /\n" + f'LABEL io.openshift.release="{release_name}" \\\n' + f' io.openshift.release.base-image-digest="{cvo_image_digest}"\n' + f"COPY {RELEASE_MANIFESTS_SUBDIR}/ /{RELEASE_MANIFESTS_SUBDIR}/\n" + ) dockerfile_path = repo_dir / "Dockerfile" await exectools.to_thread(dockerfile_path.write_text, dockerfile_content) diff --git a/doozer/tests/cli/test_release_payload.py b/doozer/tests/cli/test_release_payload.py index 751b08aa13..8ba9ffd171 100644 --- a/doozer/tests/cli/test_release_payload.py +++ b/doozer/tests/cli/test_release_payload.py @@ -208,7 +208,7 @@ async def test_rebase_writes_dockerfile_and_commits(self, mock_build_repo_class) mock_build_repo.commit_hash = "abc1234" mock_build_repo_class.return_value = mock_build_repo - art_images_pullspec = "quay.io/redhat-user-workloads/ocp-art-tenant/art-images:cvo-tag" + art_images_pullspec = "quay.io/redhat-user-workloads/ocp-art-tenant/art-images@sha256:abc123def456" with ( mock.patch.object( self.cli, "_generate_manifests", mock.AsyncMock(return_value="registry.example.com/cvo@sha256:digest") @@ -235,7 +235,11 @@ async def test_rebase_writes_dockerfile_and_commits(self, mock_build_repo_class) self.assertIs(build_repo, mock_build_repo) dockerfile_content = (self.repo_dir / "Dockerfile").read_text() - self.assertIn(f"FROM {art_images_pullspec}", dockerfile_content) + self.assertIn(f"FROM {art_images_pullspec} AS cvo", dockerfile_content) + self.assertIn("FROM scratch", dockerfile_content) + self.assertIn("COPY --from=cvo / /", dockerfile_content) + self.assertIn('io.openshift.release="4.21.1-202608011200.p0"', dockerfile_content) + self.assertIn('io.openshift.release.base-image-digest="sha256:abc123def456"', dockerfile_content) self.assertIn("COPY release-manifests/ /release-manifests/", dockerfile_content) commit_message = mock_build_repo.commit.call_args.args[0] @@ -253,17 +257,40 @@ async def test_rebase_uses_custom_commit_message(self, mock_build_repo_class): self.cli.commit_message = "Custom commit message" with ( - mock.patch.object(self.cli, "_generate_manifests", mock.AsyncMock(return_value="registry.example.com/cvo")), + mock.patch.object( + self.cli, "_generate_manifests", mock.AsyncMock(return_value="registry.example.com/cvo@sha256:digest") + ), mock.patch.object( self.cli, "_resolve_art_images_pullspec", - mock.AsyncMock(return_value="quay.io/redhat-user-workloads/ocp-art-tenant/art-images:cvo-tag"), + mock.AsyncMock( + return_value="quay.io/redhat-user-workloads/ocp-art-tenant/art-images@sha256:abc123def456" + ), ), ): await self.cli._rebase() mock_build_repo.commit.assert_awaited_once_with("Custom commit message", allow_empty=True, force=True) + @mock.patch("doozerlib.cli.release_payload.BuildRepo") + async def test_rebase_raises_when_art_images_pullspec_has_no_digest(self, mock_build_repo_class): + mock_build_repo = mock.AsyncMock() + mock_build_repo.local_dir = self.repo_dir + mock_build_repo_class.return_value = mock_build_repo + + with ( + mock.patch.object( + self.cli, "_generate_manifests", mock.AsyncMock(return_value="registry.example.com/cvo@sha256:digest") + ), + mock.patch.object( + self.cli, + "_resolve_art_images_pullspec", + mock.AsyncMock(return_value="quay.io/redhat-user-workloads/ocp-art-tenant/art-images:tag-only"), + ), + ): + with self.assertRaises(DoozerFatalError): + await self.cli._rebase() + class TestResolveArtImagesPullspec(unittest.IsolatedAsyncioTestCase): def setUp(self): From 5f7957bfb00e64e2b8ac9a28d02219ddd077db77 Mon Sep 17 00:00:00 2001 From: Ashwin Das Date: Mon, 10 Aug 2026 23:12:53 -0400 Subject: [PATCH 3/3] ART-21775: use version only for io.openshift.release label The label value should be the version string (e.g. 4.22.9), not the full version-release composite, matching what oc adm release new sets. Co-Authored-By: Claude Opus 4.6 rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED --- doozer/doozerlib/cli/release_payload.py | 3 +-- doozer/tests/cli/test_release_payload.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/doozer/doozerlib/cli/release_payload.py b/doozer/doozerlib/cli/release_payload.py index a16e2863d9..da5f00811d 100644 --- a/doozer/doozerlib/cli/release_payload.py +++ b/doozer/doozerlib/cli/release_payload.py @@ -234,7 +234,6 @@ async def _rebase(self) -> Tuple[BuildRepo, str, str]: # is a multi-arch manifest list natively accessible from within the Konflux build environment. from_pullspec = await self._resolve_art_images_pullspec(cvo_pullspec) - release_name = f"{self.version.lstrip('v')}-{self.release}" if "@" not in from_pullspec: raise DoozerFatalError( f"Expected digest-based art-images pullspec but got: {from_pullspec}" @@ -245,7 +244,7 @@ async def _rebase(self) -> Tuple[BuildRepo, str, str]: f"\n" f"FROM scratch\n" f"COPY --from=cvo / /\n" - f'LABEL io.openshift.release="{release_name}" \\\n' + f'LABEL io.openshift.release="{self.version.lstrip("v")}" \\\n' f' io.openshift.release.base-image-digest="{cvo_image_digest}"\n' f"COPY {RELEASE_MANIFESTS_SUBDIR}/ /{RELEASE_MANIFESTS_SUBDIR}/\n" ) diff --git a/doozer/tests/cli/test_release_payload.py b/doozer/tests/cli/test_release_payload.py index 8ba9ffd171..052e3842b4 100644 --- a/doozer/tests/cli/test_release_payload.py +++ b/doozer/tests/cli/test_release_payload.py @@ -238,7 +238,7 @@ async def test_rebase_writes_dockerfile_and_commits(self, mock_build_repo_class) self.assertIn(f"FROM {art_images_pullspec} AS cvo", dockerfile_content) self.assertIn("FROM scratch", dockerfile_content) self.assertIn("COPY --from=cvo / /", dockerfile_content) - self.assertIn('io.openshift.release="4.21.1-202608011200.p0"', dockerfile_content) + self.assertIn('io.openshift.release="4.21.1"', dockerfile_content) self.assertIn('io.openshift.release.base-image-digest="sha256:abc123def456"', dockerfile_content) self.assertIn("COPY release-manifests/ /release-manifests/", dockerfile_content)