Skip to content

Commit 558912f

Browse files
committed
build-sync: Add RHCOS tagging to QCI
Tag RHCOS container images (rhel-coreos, machine-os-content, rhel-coreos-extensions, rhel-coreos-10, rhel-coreos-10-extensions) to quay.io/openshift/ci during build-sync. Uses `oc tag` with reference mode to create image references without copying bytes: - --reference-policy='source' - Reference points to source location - --import-mode='PreserveOriginal' - Preserve original manifest format - --reference - Create reference without importing image ## QCI Pullspec Format Images are tagged to QCI with: - **Prunable tags** (for cleanup): `quay.io/openshift/ci:<timestamp>_prune_art__ocp_<version>_<tag>` - **Floating tags** (latest): `quay.io/openshift/ci:art__ocp_<version>_<tag>` Examples for OCP 4.17: ``` quay.io/openshift/ci:20260603120000_prune_art__ocp_4.17_rhel-coreos quay.io/openshift/ci:art__ocp_4.17_rhel-coreos quay.io/openshift/ci:art__ocp_4.17_rhel-coreos-extensions quay.io/openshift/ci:art__ocp_4.17_machine-os-content ``` For private images (openshift-priv): ``` quay.io/openshift/ci:art__ocp_4.17_priv_rhel-coreos ``` Implements support for: - QCI credentials via QCI_USER/QCI_PASSWORD environment variables - Both public and private namespace mirroring (using same source pullspec) - Proper error handling with Slack notifications - Skips for older versions (<4.12), non-stream assemblies, and custom data gitrefs
1 parent 1a84e5b commit 558912f

7 files changed

Lines changed: 758 additions & 13 deletions

File tree

.worktrees/autoclose-golang-bugs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit b133254bd2a983a81e80cf23311eaab4d46d68b2

.worktrees/provides

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit 6ec1c9b10d0b1bf04291730936fc0796cf4f49be

pyartcd/pyartcd/jira_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ def assign_to_me(self, key):
4747
def close_task(self, key):
4848
self._client.transition_issue(key, 'Closed')
4949

50+
def close_with_resolution(self, key, resolution='Not a Bug'):
51+
self._client.transition_issue(key, 'Closed', fields={'resolution': {'name': resolution}})
52+
5053
def start_task(self, key):
5154
self._client.transition_issue(key, 'In Progress')
5255

pyartcd/pyartcd/pipelines/build_sync.py

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
import os
55
import re
6+
from datetime import datetime
67

78
import click
89
import yaml
@@ -11,12 +12,14 @@
1112
from artcommonlib.constants import (
1213
KONFLUX_DEFAULT_IMAGE_REPO,
1314
REGISTRY_CI_OPENSHIFT,
15+
REGISTRY_QUAY_CI,
1416
REGISTRY_QUAY_OCP_RELEASE_DEV,
17+
REGISTRY_QUAY_OPENSHIFT,
1518
)
1619
from artcommonlib.exectools import limit_concurrency
1720
from artcommonlib.github_auth import get_github_client_for_org
1821
from artcommonlib.redis import RedisError
19-
from artcommonlib.registry_config import RegistryConfig
22+
from artcommonlib.registry_config import RegistryConfig, RegistryCredential
2023
from artcommonlib.release_util import SoftwareLifecyclePhase
2124
from artcommonlib.telemetry import start_as_current_span_async
2225
from artcommonlib.util import split_git_url, uses_konflux_imagestream_override
@@ -163,14 +166,25 @@ async def run(self):
163166

164167
source_files = [quay_auth_file]
165168

169+
# Build credentials list for QCI (quay.io/openshift/ci) if available
170+
credentials = []
171+
qci_user = os.environ.get('QCI_USER')
172+
qci_password = os.environ.get('QCI_PASSWORD')
173+
if qci_user and qci_password:
174+
credentials.append(RegistryCredential(REGISTRY_QUAY_OPENSHIFT, qci_user, qci_password))
175+
else:
176+
self.logger.warning('QCI_USER/QCI_PASSWORD not set - RHCOS images will not be mirrored to QCI')
177+
166178
with RegistryConfig(
167179
kubeconfig=os.environ.get('KUBECONFIG'),
168180
source_files=source_files,
169181
registries=[
170182
REGISTRY_QUAY_OCP_RELEASE_DEV,
171183
KONFLUX_DEFAULT_IMAGE_REPO,
172184
REGISTRY_CI_OPENSHIFT,
185+
REGISTRY_QUAY_CI,
173186
],
187+
credentials=credentials,
174188
) as global_auth_file:
175189
self._registry_config = global_auth_file
176190

@@ -434,6 +448,126 @@ async def _populate_ci_imagestreams(self):
434448
except (ChildProcessError, KeyError) as e:
435449
await self.slack_client.say(f'Unable to mirror CoreOS images to CI for {self.version}: {e}')
436450

451+
@start_as_current_span_async(TRACER, "build-sync.mirror-rhcos-to-qci")
452+
async def _mirror_rhcos_to_qci(self):
453+
"""
454+
Mirror RHCOS images to quay.io/openshift/ci (QCI) for external CI consumption.
455+
456+
Uses the same tag convention as images:streams mirror:
457+
- Prunable: {timestamp}_prune_art__ocp_{version}_{tag}
458+
- Floating: art__ocp_{version}_{tag}
459+
460+
This ensures CI jobs can pull RHCOS images directly from QCI without
461+
requiring access to the app.ci cluster's internal imagestreams.
462+
"""
463+
current_span = trace.get_current_span()
464+
current_span.set_attribute("build-sync.version", self.version)
465+
current_span.set_attribute("build-sync.assembly", self.assembly)
466+
467+
# Same guards as _populate_ci_imagestreams
468+
major, minor = [int(n) for n in self.version.split('.')]
469+
if major <= 4 and minor < 12:
470+
self.logger.info('Skipping QCI mirror for version %s (< 4.12)', self.version)
471+
return
472+
473+
if not self.assembly == 'stream' or self.doozer_data_gitref:
474+
self.logger.info('Skipping QCI mirror for non-stream assembly or custom gitref')
475+
return
476+
477+
# Check if QCI credentials are available
478+
if not os.environ.get('QCI_USER') or not os.environ.get('QCI_PASSWORD'):
479+
self.logger.warning('QCI_USER/QCI_PASSWORD not set - skipping QCI mirror')
480+
return
481+
482+
try:
483+
tags_to_transfer = rhcos.get_container_names(self.group_runtime)
484+
prune_timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
485+
486+
self.logger.info('Mirroring RHCOS images to QCI: %s', ', '.join(tags_to_transfer))
487+
488+
tasks = []
489+
for tag in tags_to_transfer:
490+
# Mirror to public QCI
491+
tasks.append(self._mirror_single_rhcos_to_qci(tag, prune_timestamp, private=False))
492+
# Mirror to private QCI (for openshift-priv CI)
493+
tasks.append(self._mirror_single_rhcos_to_qci(tag, prune_timestamp, private=True))
494+
495+
await asyncio.gather(*tasks)
496+
self.logger.info('Successfully mirrored RHCOS images to QCI')
497+
498+
except Exception as e:
499+
self.logger.error('Failed to mirror RHCOS images to QCI: %s', e)
500+
await self.slack_client.say(f'Unable to mirror CoreOS images to QCI for {self.version}: {e}')
501+
502+
@start_as_current_span_async(TRACER, "build-sync.mirror-single-rhcos-to-qci")
503+
@limit_concurrency(10)
504+
async def _mirror_single_rhcos_to_qci(self, tag: str, prune_timestamp: str, private: bool):
505+
"""
506+
Tag a single RHCOS image to QCI using reference mode.
507+
508+
Creates both a prunable tag (for cleanup) and a floating tag (always points to latest).
509+
Uses oc tag with --reference to create references without copying image bytes.
510+
511+
For private mirroring, we use the same source pullspec from the public ART imagestream
512+
(consistent with how _tag_into_ci_imagestream handles private CI).
513+
514+
Args:
515+
tag: RHCOS container name (e.g., 'rhel-coreos', 'rhel-coreos-extensions')
516+
prune_timestamp: Timestamp string for prunable tag naming
517+
private: Whether to add _priv suffix for private CI consumption
518+
"""
519+
current_span = trace.get_current_span()
520+
current_span.set_attribute("build-sync.tag", tag)
521+
current_span.set_attribute("build-sync.private", private)
522+
523+
# Always get pullspec from public ART imagestream (same source for both public and private,
524+
# consistent with _tag_into_ci_imagestream which uses the same pullspec for private CI)
525+
namespace = 'ocp'
526+
is_name = self.is_base_name
527+
528+
# Get the pullspec from the ART imagestream (already multi-arch)
529+
cmd = (
530+
f'oc --kubeconfig {os.environ["KUBECONFIG"]} -n {namespace} '
531+
f'get istag/{is_name}:{tag} -o=jsonpath={{{{.tag.from.name}}}}'
532+
)
533+
rc, tag_pullspec, stderr = await exectools.cmd_gather_async(cmd, check=False)
534+
tag_pullspec = tag_pullspec.strip()
535+
536+
if rc != 0 or not tag_pullspec:
537+
self.logger.warning(
538+
'No pullspec found for %s/%s:%s (rc=%d, stderr=%s)', namespace, is_name, tag, rc, stderr
539+
)
540+
return
541+
542+
# Build QCI tag names following the images:streams mirror convention
543+
# Replace problematic characters with underscores for valid tag names
544+
priv_suffix = '_priv' if private else ''
545+
qci_tag_base = f"ocp_{self.version}{priv_suffix}_{tag}"
546+
547+
prunable_tag = f"{prune_timestamp}_prune_art__{qci_tag_base}"
548+
floating_tag = f"art__{qci_tag_base}"
549+
550+
self.logger.info('Tagging %s/%s:%s to QCI: %s', namespace, is_name, tag, floating_tag)
551+
552+
if self.runtime.dry_run:
553+
self.logger.info('[DRY RUN] Would tag %s -> %s, %s', tag_pullspec, prunable_tag, floating_tag)
554+
return
555+
556+
# Tag to QCI using reference mode - creates references without copying bytes
557+
# Use separate commands for prunable and floating to handle potential partial failures
558+
for qci_tag in [prunable_tag, floating_tag]:
559+
dest = f"{REGISTRY_QUAY_CI}:{qci_tag}"
560+
cmd = (
561+
f'oc --kubeconfig {os.environ["KUBECONFIG"]} '
562+
f'tag {tag_pullspec} {dest} '
563+
f"--reference-policy='source' --import-mode='PreserveOriginal' --reference"
564+
)
565+
try:
566+
await exectools.cmd_assert_async(cmd, retries=3)
567+
except ChildProcessError as e:
568+
self.logger.error('Failed to tag %s to %s: %s', tag_pullspec, dest, e)
569+
raise
570+
437571
@start_as_current_span_async(TRACER, "build-sync.update-nightly-imagestreams")
438572
async def _update_nightly_imagestreams(self):
439573
"""
@@ -491,6 +625,9 @@ async def _update_nightly_imagestreams(self):
491625
# Populate CI imagestreams
492626
await self._populate_ci_imagestreams()
493627

628+
# Mirror RHCOS images to QCI for external CI consumption
629+
await self._mirror_rhcos_to_qci()
630+
494631
if self.publish:
495632
# Run 'oc adm release new' in parallel
496633
tasks = []

pyartcd/pyartcd/pipelines/rebuild_golang_rpms.py

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import json
44
import logging
55
import os
6-
from typing import List
6+
from typing import Dict, List, Tuple
77

88
import click
99
import koji
@@ -26,6 +26,13 @@
2626

2727
_LOGGER = logging.getLogger(__name__)
2828

29+
# RPMs excluded from rebuild per OCP version.
30+
# Key: (major, minor) tuple representing the minimum version (inclusive).
31+
# Value: list of RPM names to exclude from that version onward.
32+
EXCLUDED_RPMS: Dict[Tuple[int, int], List[str]] = {
33+
(4, 19): ['ignition'],
34+
}
35+
2936

3037
class RebuildGolangRPMsPipeline:
3138
def __init__(
@@ -51,15 +58,25 @@ def __init__(
5158
self.all = all
5259
self.koji_session = koji.ClientSession(BREW_HUB)
5360

54-
async def get_rpms_from_open_trackers(self) -> List[str]:
61+
def get_excluded_rpms(self) -> set:
62+
"""Return the set of RPM names excluded for this OCP version."""
63+
major, minor = (int(x) for x in self.ocp_version.split('.'))
64+
excluded = set()
65+
for (min_major, min_minor), rpms in EXCLUDED_RPMS.items():
66+
if (major, minor) >= (min_major, min_minor):
67+
excluded.update(rpms)
68+
return excluded
69+
70+
async def get_rpms_from_open_trackers(self) -> Tuple[List[str], List[dict]]:
5571
"""Fetch RPMs that need rebuilding using find-bugs:golang command.
5672
5773
This method invokes the find-bugs:golang command to identify which RPMs
58-
are affected by golang security issues and returns the list of RPMs
59-
that need to be rebuilt.
74+
are affected by golang security issues. RPMs in the exclusion list are
75+
separated out so their trackers can be closed.
6076
6177
Returns:
62-
List[str]: List of RPM names that need rebuilding
78+
Tuple of (rpms_to_rebuild, excluded_bugs) where excluded_bugs is a
79+
list of dicts with 'id' and 'pscomponent' keys.
6380
"""
6481
_LOGGER.info('Fetching RPMs affected by golang security issues from find-bugs:golang')
6582

@@ -85,24 +102,56 @@ async def get_rpms_from_open_trackers(self) -> List[str]:
85102

86103
if not stdout or not stdout.strip():
87104
_LOGGER.info('No output from find-bugs:golang command, no unfixed bugs found')
88-
return []
105+
return [], []
89106

90107
json_data = json.loads(stdout)
91108
if not json_data:
92109
_LOGGER.info('No unfixed bugs found in find-bugs:golang output')
93-
return []
110+
return [], []
111+
112+
excluded_rpm_names = self.get_excluded_rpms()
94113

95114
# Extract RPM names (pscomponent) from unfixed bugs
96115
rpms_to_rebuild = set()
116+
excluded_bugs = []
97117
for bug in json_data:
98118
# better to panic here if pscomponent is missing since it means find-bugs:golang output format has changed in an unexpected way
99119
component = bug['pscomponent']
100-
rpms_to_rebuild.add(component)
101-
_LOGGER.info(f"Adding RPM {component} from unfixed bug {bug['id']}")
120+
if component in excluded_rpm_names:
121+
_LOGGER.info(f"RPM {component} is excluded for OCP {self.ocp_version}, will close tracker {bug['id']}")
122+
excluded_bugs.append({'id': bug['id'], 'pscomponent': component})
123+
else:
124+
rpms_to_rebuild.add(component)
125+
_LOGGER.info(f"Adding RPM {component} from unfixed bug {bug['id']}")
102126

103127
rpms_list = sorted(list(rpms_to_rebuild))
104128
_LOGGER.info(f'Found {len(rpms_list)} RPMs to rebuild from find-bugs:golang: {rpms_list}')
105-
return rpms_list
129+
if excluded_bugs:
130+
_LOGGER.info(f'Found {len(excluded_bugs)} excluded RPM trackers to close')
131+
return rpms_list, excluded_bugs
132+
133+
async def close_excluded_rpm_trackers(self, excluded_bugs: List[dict]):
134+
"""Close tracker bugs for RPMs that are excluded from this OCP version.
135+
136+
Args:
137+
excluded_bugs: List of dicts with 'id' and 'pscomponent' keys.
138+
"""
139+
if not excluded_bugs:
140+
return
141+
142+
jira_client = self.runtime.new_jira_client()
143+
for bug in excluded_bugs:
144+
bug_id = bug['id']
145+
rpm = bug['pscomponent']
146+
comment = f"{rpm} is not shipped in OCP {self.ocp_version}. Closing tracker as Not a Bug."
147+
148+
if self.runtime.dry_run:
149+
_LOGGER.info(f"[DRY RUN] Would close {bug_id} with resolution 'Not a Bug' and comment: {comment}")
150+
continue
151+
152+
_LOGGER.info(f"Closing tracker {bug_id} for excluded RPM {rpm}")
153+
jira_client.add_comment(bug_id, comment)
154+
jira_client.close_with_resolution(bug_id, resolution='Not a Bug')
106155

107156
async def run(self):
108157
go_version, el_nvr_map = extract_and_validate_golang_nvrs(self.ocp_version, self.go_nvrs)
@@ -112,11 +161,21 @@ async def run(self):
112161
# If all=False and rpms is empty, fetch RPMs from find-bugs:golang
113162
if not self.all and not self.rpms:
114163
_LOGGER.info('all=False and rpms is empty, fetching RPMs from find-bugs:golang')
115-
self.rpms = await self.get_rpms_from_open_trackers()
164+
self.rpms, excluded_bugs = await self.get_rpms_from_open_trackers()
165+
await self.close_excluded_rpm_trackers(excluded_bugs)
116166
if not self.rpms:
117167
_LOGGER.info('No open rpm trackers found, exiting pipeline')
118168
return
119169

170+
# Filter out excluded RPMs from explicit lists (--rpms or --all)
171+
excluded_rpm_names = self.get_excluded_rpms()
172+
if self.rpms:
173+
filtered = [r for r in self.rpms if r not in excluded_rpm_names]
174+
skipped = [r for r in self.rpms if r in excluded_rpm_names]
175+
if skipped:
176+
_LOGGER.warning(f"Excluding RPMs not shipped in OCP {self.ocp_version}: {skipped}")
177+
self.rpms = filtered
178+
120179
# For rpms - first make sure builds are tagged and available
121180
_LOGGER.info('Checking if golang builds are tagged and available in rpm buildroots')
122181
error_msg = ""

0 commit comments

Comments
 (0)