Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 52 additions & 6 deletions elliott/elliottlib/cli/konflux_release_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
import re
import sys
from dataclasses import dataclass
from typing import List, Optional, Set
from pathlib import Path
from typing import List, Mapping, Optional, Set

import aiohttp
import click
Expand Down Expand Up @@ -87,7 +88,23 @@ async def _validate_snapshot_against_single_rpa(kind: str, rpa_name: str, snapsh
)


async def validate_snapshot_against_rpa(group: str, env: str, kind: str, snapshot_components: List[str]) -> None:
async def validate_snapshot_against_rpa(
group: str,
env: str,
kind: str,
snapshot_components: List[str],
release_plans: Mapping[str, str] | None = None,
) -> None:
"""
Validate snapshot components against the configured stage and prod RPAs.

Args:
group: Build-data group used to determine whether RPA validation applies.
env: Environment being released first; the other environment is checked second.
kind: Shipment kind used by the legacy RPA naming fallback.
snapshot_components: Component names present in the shipment snapshot.
release_plans: Optional mapping of environment names to configured ReleasePlans.
"""
match = re.fullmatch(r"openshift-(\d+)\.(\d+)", group)
if group.startswith("openshift-") and not match:
raise ValueError(f"Unrecognized openshift group format, refusing to skip RPA validation: {group!r}")
Expand All @@ -108,7 +125,9 @@ async def validate_snapshot_against_rpa(group: str, env: str, kind: str, snapsho

envs_to_check = [env] + [e for e in OCP_RPA_ENVS if e != env]
for check_env in envs_to_check:
rpa_name = f"{OCP_RPA_KINDS[kind]}-{check_env}-{major}-{minor}"
rpa_name = (release_plans or {}).get(check_env)
if not rpa_name:
rpa_name = f"{OCP_RPA_KINDS[kind]}-{check_env}-{major}-{minor}"
await _validate_snapshot_against_single_rpa(kind, rpa_name, snapshot_components)


Expand Down Expand Up @@ -234,7 +253,17 @@ async def run(self) -> Optional[ResourceInstance]:
if self.runtime.group.startswith("openshift-") and config.shipment.snapshot:
LOGGER.info("Validating snapshot components against RPA...")
component_names = [c.name for c in config.shipment.snapshot.spec.components]
await validate_snapshot_against_rpa(self.runtime.group, self.release_env, self.kind, component_names)
release_plans = {
"stage": config.shipment.environments.stage.releasePlan,
"prod": config.shipment.environments.prod.releasePlan,
}
await validate_snapshot_against_rpa(
self.runtime.group,
self.release_env,
self.kind,
component_names,
release_plans=release_plans,
)

# Create snapshot first using the spec from shipment config
LOGGER.info("Creating snapshot from shipment config...")
Expand Down Expand Up @@ -265,12 +294,25 @@ async def run(self) -> Optional[ResourceInstance]:

def get_object_name(self) -> str:
timestamp = get_utc_now_formatted_str()
raw_prefix = f"{self.runtime.product}-{self.release_env}-{self.runtime.assembly}-{self.kind}"
object_kind = self._get_object_kind()
raw_prefix = f"{self.runtime.product}-{self.release_env}-{self.runtime.assembly}-{object_kind}"
prefix = normalize_k8s_dns_label(raw_prefix, max_length=63 - len(timestamp) - 1)
if not prefix:
raise ValueError(f"Release object name prefix {raw_prefix!r} cannot be normalized to a Kubernetes name")
return f"{prefix}-{timestamp}"

def _get_object_kind(self) -> str:
"""
Return the shipment kind qualified by its RHEL version, when present.

MicroShift bootc shipments have one configuration file per RHEL version,
so the version must be included in Kubernetes object names to avoid
collisions when the releases run concurrently.
"""
config_stem = Path(self.config_path).stem
match = re.search(rf"(?:^|\.)({re.escape(self.kind)}-el\d+)(?:\.|$)", config_stem)
return match.group(1) if match else self.kind

async def create_snapshot(self, shipment: Shipment) -> dict:
"""
Create a Konflux Snapshot manifest from the given shipment's snapshot spec.
Expand Down Expand Up @@ -538,5 +580,9 @@ async def validate_rpa_cli(runtime: Runtime, config, env, kind):
component_names = [c.name for c in shipment_config.shipment.snapshot.spec.components]
LOGGER.info("Validating %d components against RPA for %s/%s...", len(component_names), env, kind)

await validate_snapshot_against_rpa(runtime.group, env, kind, component_names)
release_plans = {
"stage": shipment_config.shipment.environments.stage.releasePlan,
"prod": shipment_config.shipment.environments.prod.releasePlan,
}
await validate_snapshot_against_rpa(runtime.group, env, kind, component_names, release_plans=release_plans)
LOGGER.info("Validation passed: all components are present in the RPA")
30 changes: 29 additions & 1 deletion elliott/elliottlib/shipment_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ def get_shipment_configs_from_mr(

filename = file_path.split('/')[-1]
parts = filename.replace('.yaml', '').replace('.yml', '')
kind = next((k for k in kinds if k in parts), None)
kind = _get_shipment_config_kind(parts, kinds)
if not kind:
continue

Expand All @@ -228,6 +228,34 @@ def get_shipment_configs_from_mr(
return shipment_configs


def _get_shipment_config_kind(filename_stem: str, kinds: Tuple[str, ...]) -> str | None:
"""
Extracts a shipment kind from a filename, preserving an optional RHEL suffix.

New multi-RHEL shipment files use names such as ``image-el9`` and
``microshift-bootc-el10``. The suffix must remain part of the returned key so
that multiple RHEL-specific configs can coexist in one merge request.

Args:
filename_stem: Shipment filename without its YAML extension.
kinds: Base shipment kinds accepted by the caller.
Returns:
The matching base or RHEL-qualified shipment kind, if any.
"""
for kind in sorted(kinds, key=len, reverse=True):
qualified_match = re.search(rf"(?:^|\.)({re.escape(kind)}-el\d+)(?:\.|$)", filename_stem)
if qualified_match:
return qualified_match.group(1)

base_match = re.search(rf"(?:^|\.){re.escape(kind)}(?:\.|$)", filename_stem)
if base_match:
return kind

# Preserve the historical substring matching for unusual legacy filenames such as
# ``rpm-extra.yaml``.
return next((kind for kind in kinds if kind in filename_stem), None)


def get_shipment_config_from_mr(mr_url: str, kind: str) -> ShipmentConfig | None:
"""Fetch a specific shipment config from a merge request URL."""
shipment_configs = get_shipment_configs_from_mr(mr_url)
Expand Down
43 changes: 43 additions & 0 deletions elliott/tests/test_konflux_release_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,26 @@ def test_object_name_is_normalized_and_preserves_timestamp(self, mock_konflux_cl
self.assertEqual(name, "ocp-prod-4-18-2-rc-1-image-v2-20260805200004")
self.assertLessEqual(len(name), 63)

@patch("elliottlib.cli.konflux_release_cli.get_utc_now_formatted_str", return_value="20260909154630")
@patch("doozerlib.backend.konflux_client.KonfluxClient.from_kubeconfig")
def test_object_name_includes_rhel_suffix_from_shipment_filename(self, mock_konflux_client_init, _mock_timestamp):
mock_konflux_client_init.return_value = self.konflux_client
self.runtime.assembly = "rc.1"

cli = CreateReleaseCli(
runtime=self.runtime,
config_path=(
"shipment/ocp/openshift-5.0/openshift-5-0/stage/rc.1.microshift-bootc-el10.20260904133832.yaml"
),
release_env="stage",
konflux_config=self.konflux_config,
image_repo_pull_secret=self.image_repo_pull_secret,
dry_run=self.dry_run,
kind="microshift-bootc",
)

self.assertEqual(cli.get_object_name(), "ocp-stage-rc-1-microshift-bootc-el10-20260909154630")

@patch("doozerlib.backend.konflux_client.KonfluxClient.from_kubeconfig")
async def test_release_rejects_invalid_snapshot_reference(self, mock_konflux_client_init):
mock_konflux_client_init.return_value = self.konflux_client
Expand Down Expand Up @@ -787,6 +807,29 @@ async def test_validate_rpa_checks_both_envs_stage_first(self, mock_fetch_rpa):
calls = [c.args[0] for c in mock_fetch_rpa.await_args_list]
self.assertEqual(calls, ["ocp-art-advisory-stage-4-18", "ocp-art-advisory-prod-4-18"])

@patch("elliottlib.cli.konflux_release_cli.fetch_rpa", new_callable=AsyncMock)
async def test_validate_rpa_uses_configured_release_plans(self, mock_fetch_rpa):
"""Uses RHEL-specific ReleasePlans from the shipment configuration when provided."""
rpa_data = {"spec": {"data": {"mapping": {"components": [{"name": "comp1"}]}}}}
mock_fetch_rpa.return_value = rpa_data

await validate_snapshot_against_rpa(
"openshift-5.0",
"stage",
"microshift-bootc",
["comp1"],
release_plans={
"stage": "ocp-art-advisory-stage-5-0-rhel9",
"prod": "ocp-art-advisory-prod-5-0-rhel9",
},
)

calls = [c.args[0] for c in mock_fetch_rpa.await_args_list]
self.assertEqual(
calls,
["ocp-art-advisory-stage-5-0-rhel9", "ocp-art-advisory-prod-5-0-rhel9"],
)

@patch("elliottlib.cli.konflux_release_cli.fetch_rpa", new_callable=AsyncMock)
async def test_validate_rpa_skipped_for_non_openshift(self, mock_fetch_rpa):
await validate_snapshot_against_rpa("oadp-1.5", "prod", "image", ["comp1"])
Expand Down
26 changes: 26 additions & 0 deletions elliott/tests/test_shipment_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,32 @@ def test_get_shipment_configs_by_kind_all_default_kinds(self, mock_gitlab_class)
expected_kinds = {"fbc", "image", "extras", "microshift-bootc", "metadata"}
self.assertEqual(set(result.keys()), expected_kinds)

@patch('artcommonlib.gitlab.gitlab.Gitlab')
@patch.dict(os.environ, {'GITLAB_TOKEN': 'test-token'})
def test_get_shipment_configs_preserves_rhel_qualified_kinds(self, mock_gitlab_class):
"""RHEL-qualified shipment files remain distinct when parsed from one MR."""
mock_gitlab = mock_gitlab_class.return_value
mock_gitlab.projects.get.side_effect = [self.mock_project, self.mock_source_project]

self.mock_project.mergerequests.get.return_value = self.mock_mr
self.mock_mr.source_project_id = "source-project-id"
self.mock_mr.source_branch = "test-branch"

self.mock_diff_info.id = "diff-id"
self.mock_mr.diffs.list.return_value = [self.mock_diff_info]
self.mock_mr.diffs.get.return_value = self.mock_diff
self.mock_diff.diffs = [
{'new_path': 'microshift-bootc-el9.yaml', 'old_path': None},
{'new_path': 'microshift-bootc-el10.yaml', 'old_path': None},
]

self.mock_file_content.decode.return_value.decode.return_value = self.sample_yaml_content
self.mock_source_project.files.get.return_value = self.mock_file_content

result = shipment_utils.get_shipment_configs_from_mr(self.test_mr_url)

self.assertEqual(set(result), {'microshift-bootc-el9', 'microshift-bootc-el10'})


class TestGroupFiltering(unittest.TestCase):
"""Test cases for group-based filtering in get_shipment_configs_from_mr"""
Expand Down
26 changes: 3 additions & 23 deletions pyartcd/pyartcd/pipelines/binary_release_konflux.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
from artcommonlib.build_visibility import is_nvr_embargoed
from artcommonlib.constants import SHIPMENT_DATA_URL_TEMPLATE
from artcommonlib.gitlab import GitLabClient
from artcommonlib.release_util import isolate_el_version_in_release
from artcommonlib.util import new_roundtrip_yaml_handler
from elliottlib.shipment_model import (
Environments,
Expand All @@ -32,6 +31,7 @@
from pyartcd.click_validators import validate_release_date
from pyartcd.git import GitRepository
from pyartcd.runtime import Runtime
from pyartcd.shipment_utils import get_release_plan_names, group_nvrs_by_rhel_version

yaml = new_roundtrip_yaml_handler()

Expand Down Expand Up @@ -237,14 +237,7 @@ def _group_nvrs_by_rhel_version(nvrs: List[str]) -> Dict[str, List[str]]:
NVRs without a detectable .el* suffix go under the 'default' key.
Returns an OrderedDict-like dict sorted by key for deterministic ordering.
"""
groups: Dict[str, List[str]] = {}
for nvr in nvrs:
# The release field is the last hyphen-delimited segment of an NVR
release = nvr.rsplit('-', 1)[-1] if '-' in nvr else nvr
el_ver = isolate_el_version_in_release(release)
key = f"el{el_ver}" if el_ver is not None else "default"
groups.setdefault(key, []).append(nvr)
return dict(sorted(groups.items()))
return group_nvrs_by_rhel_version(nvrs)

async def create_snapshot(self, builds: List[str]) -> Optional[Snapshot]:
"""
Expand Down Expand Up @@ -316,21 +309,8 @@ def create_shipment_config(self, snapshot: Snapshot, rhel_suffix: Optional[str]
fbc=False,
)

stage_rpa = "n/a"
prod_rpa = "n/a"
config_path = self.shipment_data_repo._directory / "config.yaml"
if config_path.exists():
with open(config_path, 'r') as f:
shipment_config = stdlib_yaml.safe_load(f) or {}
applications = shipment_config.get("applications", {})
# Try RHEL-versioned key first (e.g. 'oc-mirror-2-0-el9'), then fall back to the
# plain application name for products that don't split by RHEL version.
lookup_key = f"{application}-{rhel_suffix}" if rhel_suffix else application
app_env_config = (applications.get(lookup_key) or applications.get(application) or {}).get(
"environments", {}
)
stage_rpa = app_env_config.get("stage", {}).get("releasePlan", "n/a")
prod_rpa = app_env_config.get("prod", {}).get("releasePlan", "n/a")
stage_rpa, prod_rpa = get_release_plan_names(config_path, application, rhel_suffix)

if stage_rpa == "n/a" or prod_rpa == "n/a":
effective_key = f"{application}-{rhel_suffix}" if rhel_suffix else application
Expand Down
Loading
Loading