|
3 | 3 | import json |
4 | 4 | import os |
5 | 5 | import re |
| 6 | +from datetime import datetime |
6 | 7 |
|
7 | 8 | import click |
8 | 9 | import yaml |
|
11 | 12 | from artcommonlib.constants import ( |
12 | 13 | KONFLUX_DEFAULT_IMAGE_REPO, |
13 | 14 | REGISTRY_CI_OPENSHIFT, |
| 15 | + REGISTRY_QUAY_CI, |
14 | 16 | REGISTRY_QUAY_OCP_RELEASE_DEV, |
| 17 | + REGISTRY_QUAY_OPENSHIFT, |
15 | 18 | ) |
16 | 19 | from artcommonlib.exectools import limit_concurrency |
17 | 20 | from artcommonlib.github_auth import get_github_client_for_org |
18 | 21 | from artcommonlib.redis import RedisError |
19 | | -from artcommonlib.registry_config import RegistryConfig |
| 22 | +from artcommonlib.registry_config import RegistryConfig, RegistryCredential |
20 | 23 | from artcommonlib.release_util import SoftwareLifecyclePhase |
21 | 24 | from artcommonlib.telemetry import start_as_current_span_async |
22 | 25 | from artcommonlib.util import split_git_url, uses_konflux_imagestream_override |
@@ -163,14 +166,25 @@ async def run(self): |
163 | 166 |
|
164 | 167 | source_files = [quay_auth_file] |
165 | 168 |
|
| 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 | + |
166 | 178 | with RegistryConfig( |
167 | 179 | kubeconfig=os.environ.get('KUBECONFIG'), |
168 | 180 | source_files=source_files, |
169 | 181 | registries=[ |
170 | 182 | REGISTRY_QUAY_OCP_RELEASE_DEV, |
171 | 183 | KONFLUX_DEFAULT_IMAGE_REPO, |
172 | 184 | REGISTRY_CI_OPENSHIFT, |
| 185 | + REGISTRY_QUAY_CI, |
173 | 186 | ], |
| 187 | + credentials=credentials, |
174 | 188 | ) as global_auth_file: |
175 | 189 | self._registry_config = global_auth_file |
176 | 190 |
|
@@ -434,6 +448,126 @@ async def _populate_ci_imagestreams(self): |
434 | 448 | except (ChildProcessError, KeyError) as e: |
435 | 449 | await self.slack_client.say(f'Unable to mirror CoreOS images to CI for {self.version}: {e}') |
436 | 450 |
|
| 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 | + |
437 | 571 | @start_as_current_span_async(TRACER, "build-sync.update-nightly-imagestreams") |
438 | 572 | async def _update_nightly_imagestreams(self): |
439 | 573 | """ |
@@ -491,6 +625,9 @@ async def _update_nightly_imagestreams(self): |
491 | 625 | # Populate CI imagestreams |
492 | 626 | await self._populate_ci_imagestreams() |
493 | 627 |
|
| 628 | + # Mirror RHCOS images to QCI for external CI consumption |
| 629 | + await self._mirror_rhcos_to_qci() |
| 630 | + |
494 | 631 | if self.publish: |
495 | 632 | # Run 'oc adm release new' in parallel |
496 | 633 | tasks = [] |
|
0 commit comments