Skip to content

feat(konflux): implement base image snapshot-to-release workflow#2476

Open
lgarciaaco wants to merge 1 commit intoopenshift-eng:mainfrom
lgarciaaco:feat/base-image-snapshot-release
Open

feat(konflux): implement base image snapshot-to-release workflow#2476
lgarciaaco wants to merge 1 commit intoopenshift-eng:mainfrom
lgarciaaco:feat/base-image-snapshot-release

Conversation

@lgarciaaco
Copy link
Contributor

Summary

Implement conditional snapshot-to-release workflow for golang base images that enhances existing Konflux build completion processes while maintaining backward compatibility. Adds base image detection, snapshot-release orchestration, and proper Release state monitoring.

Problem

Before: Only quay.io URLs are updated directly after build completion, with no snapshot/release workflow

After: Base images should trigger snapshot creation → release creation → dual URL updates (both quay.io and registry.redhat.io) in streams.yml with proper conditional control via base_only=true and snapshot_release=true configuration flags

Add conditional snapshot-release workflow for golang base images with proper
Release monitoring and comprehensive test coverage. Base images with both
base_only=true and snapshot_release=true now trigger automated snapshot creation,
release generation, and proper completion tracking.

Key components:
- BaseImageHandler orchestrates 5-step workflow (snapshot→release→monitoring)
- Elliott create_release_from_snapshot function bridges snapshot and release
- Fixed Release monitoring to handle Progressing→Succeeded/Failed transitions
- Added snapshot_release schema property for granular workflow control
- Enhanced conditional logic prevents workflow from running on regular images
@openshift-ci
Copy link
Contributor

openshift-ci bot commented Feb 12, 2026

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign ashwindasr for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 12, 2026

Walkthrough

This change introduces a complete snapshot-to-release workflow for Golang base images, integrating Doozer with Konflux/Elliott. It adds base image completion handlers, NVR extraction logic, release creation capabilities, configuration predicates, and comprehensive test coverage across multiple components.

Changes

Cohort / File(s) Summary
Base Image Handler
doozer/doozerlib/backend/base_image_handler.py
New module orchestrating snapshot-to-release workflow for base images, including snapshot creation, release derivation, and completion monitoring with error handling and dry-run support.
Image Builder Integration
doozer/doozerlib/backend/konflux_image_builder.py
Adds post-build handling for base images, including NVR extraction from image labels and invocation of BaseImageHandler for completion workflow.
Image Metadata
doozer/doozerlib/image.py
Introduces two predicate methods: is_base_image() and is_snapshot_release_enabled() for querying image configuration flags.
Elliott Release CLI
elliott/elliottlib/cli/konflux_release_cli.py
New library function create_release_from_snapshot() and corresponding CLI command release-from-snapshot for creating releases from existing Konflux snapshots.
Elliott Snapshot CLI
elliott/elliottlib/cli/snapshot_cli.py
Extends snapshot creation with optional custom_name parameter to allow custom snapshot naming, prioritizing provided names over auto-generated ones.
Configuration Schema
ocp-build-data-validator/validator/json_schemas/image_config.base.schema.json
Adds snapshot_release boolean property to image configuration schema with multiple property references.
Test Suite
doozer/tests/backend/test_base_image_handler.py, doozer/tests/test_image.py, elliott/tests/test_konflux_release_cli.py
New async and unit tests covering BaseImageHandler success/failure paths, ImageMetadata predicates, and create_release_from_snapshot functionality.

Sequence Diagram

sequenceDiagram
    participant KB as Konflux Image Builder
    participant BIH as BaseImageHandler
    participant EC as Elliott CLI
    participant KC as Konflux Client
    participant DB as Kubernetes/DB

    KB->>KB: Build image successfully
    KB->>BIH: __init__(metadata, nvr, pullspec)
    activate BIH
    BIH->>BIH: Resolve Konflux namespace & kubeconfig
    BIH->>KC: Create KonfluxClient
    
    KB->>BIH: process_base_image_completion()
    activate BIH
    BIH->>EC: Elliott create_snapshot(nvr)
    activate EC
    EC->>DB: Create snapshot resource
    EC-->>BIH: snapshot_name
    deactivate EC
    
    BIH->>EC: create_release_from_snapshot(snapshot_name, release_plan)
    activate EC
    EC->>KC: Get snapshot from Konflux
    EC->>KC: Create release resource
    EC-->>BIH: release_name
    deactivate EC
    
    BIH->>BIH: _wait_for_release_completion(release_name, timeout)
    loop Poll status
        BIH->>KC: Get release status
        KC-->>BIH: Status (Pending/Success/Failure)
    end
    
    BIH-->>KB: (release_name, snapshot_name)
    deactivate BIH
    KB->>KB: Log completion summary
    deactivate BIH
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Comment @coderabbitai help to get the list of available commands and usage tips.

@openshift-ci
Copy link
Contributor

openshift-ci bot commented Feb 12, 2026

@lgarciaaco: The following test failed, say /retest to rerun all failed tests or /retest-required to rerun all mandatory failed tests:

Test name Commit Details Required Rerun command
ci/prow/security 07c286d link false /test security

Full PR test history. Your PR dashboard.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. I understand the commands that are listed here.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
elliott/elliottlib/cli/snapshot_cli.py (1)

289-294: ⚠️ Potential issue | 🟠 Major

Potential name collision when custom name is used with multiple applications.

When custom_name is provided and builds span multiple Konflux applications, all snapshots will share the same name (line 294). Since Kubernetes requires unique resource names within a namespace, creating the second snapshot will fail with a conflict error.

Consider appending an index even when custom_name is provided:

🐛 Proposed fix to ensure unique names
-                "name": snapshot_name if self.custom_name else f"{snapshot_name}-{index}",
+                "name": snapshot_name if (self.custom_name and len(app_components) == 1) else f"{snapshot_name}-{index}",
🤖 Fix all issues with AI agents
In `@doozer/doozerlib/backend/base_image_handler.py`:
- Around line 125-128: The snapshot naming can collide when multiple runs occur
within the same second; update how the timestamp is generated for
custom_snapshot_name in base_image_handler.py by using a higher-resolution UTC
timestamp (include microseconds or milliseconds) instead of
get_utc_now_formatted_str() so group_safe-{component_name}-{timestamp} becomes
unique for rapid/retry runs; modify the code that builds timestamp (or replace
get_utc_now_formatted_str) to include %f (microseconds) or an equivalent
high-resolution UTC value before constructing custom_snapshot_name that
currently uses runtime.group and component_name.
- Around line 187-202: The current dry-run still calls
create_release_from_snapshot which performs cluster interactions; to avoid that,
short-circuit when self.dry_run is true by constructing the synthetic release
name locally instead of invoking create_release_from_snapshot: detect dry-run
(self.dry_run or set elliott_runtime.dry_run), build the synthetic name using
the same naming convention used by create_release_from_snapshot, assign it to
release_name, and skip calling create_release_from_snapshot; keep the existing
ElliottRuntime setup (ElliottRuntime, elliott_runtime.group/assembly/product)
only if needed for name construction, otherwise avoid cluster-using helper calls
entirely.

In `@doozer/doozerlib/backend/konflux_image_builder.py`:
- Around line 992-1078: The handler currently re-inspects the built image for
NVR with _extract_nvr_from_build and may skip workflow; instead, change the
build() call site to pass the already-computed nvr into
_handle_base_image_completion (add a new parameter like nvr: Optional[str]),
update _handle_base_image_completion to use the passed nvr directly (skip
calling _extract_nvr_from_build) and only fall back to extraction if the passed
nvr is None, and remove or stop calling _extract_nvr_from_build where no
fallback is needed; ensure you pass the nvr into BaseImageHandler(metadata, nvr,
image_pullspec, ...) and update any callers/signature uses accordingly.

In `@elliott/elliottlib/cli/konflux_release_cli.py`:
- Around line 495-547: The CLI currently hardcodes 'ocp-art-tenant' as a
fallback namespace; after calling runtime.initialize(build_system='konflux') in
release_from_snapshot_cli, call
resolve_konflux_namespace_by_product(runtime.product, konflux_namespace), store
the result (e.g., resolved_namespace), pass resolved_namespace into
create_release_from_snapshot (namespace=resolved_namespace) and use
resolved_namespace in the LOGGER.info/green_print message instead of the literal
fallback; ensure any place that referenced konflux_namespace or used the
hardcoded 'ocp-art-tenant' uses the resolved_namespace consistently.

In `@elliott/elliottlib/cli/snapshot_cli.py`:
- Around line 203-211: The code uses custom_name directly when set, skipping
Kubernetes name validation done for auto-generated names; update the branch that
handles self.custom_name to validate or normalize it with the same rules as
normalize_group_name_for_k8s (or call that function on self.custom_name), and if
the result is empty or still invalid raise a ValueError with a clear message;
ensure snapshot_name is assigned the validated/normalized value (and retain the
timestamp suffix behavior only for auto-generated names using
get_utc_now_formatted_str()).
🧹 Nitpick comments (2)
elliott/elliottlib/cli/snapshot_cli.py (1)

105-106: Type hint should use str | None for optional parameter.

The static analysis correctly flags that custom_name: str = None should be custom_name: str | None = None per PEP 484. However, this matches the existing pattern on line 104 (job_url: str = None), so this is consistent with current codebase style.

✨ Optional fix for type hints
-        job_url: str = None,
-        custom_name: str = None,
+        job_url: str | None = None,
+        custom_name: str | None = None,
ocp-build-data-validator/validator/json_schemas/image_config.base.schema.json (1)

176-186: Consider enforcing snapshot_release only for base images.
The description says it applies only to base_only images, but the schema currently allows it everywhere. A small if/then guard would prevent misconfiguration.

Suggested schema guard
@@
-  "additionalProperties": false
+  "allOf": [
+    {
+      "if": { "properties": { "snapshot_release": { "const": true } } },
+      "then": {
+        "properties": { "base_only": { "const": true } },
+        "required": ["base_only"]
+      }
+    }
+  ],
+  "additionalProperties": false

Comment on lines +125 to +128
timestamp = get_utc_now_formatted_str()
group_safe = self.runtime.group.replace('openshift-', '').replace('.', '-')
custom_snapshot_name = f"{group_safe}-{component_name}-{timestamp}"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Snapshot name can collide within the same second.
Because --name is explicit, concurrent runs or quick retries in the same second can fail with name collisions. Consider microsecond granularity to reduce this risk.

🔧 Suggested fix
-            timestamp = get_utc_now_formatted_str()
+            timestamp = get_utc_now_formatted_str(microseconds=True)
🤖 Prompt for AI Agents
In `@doozer/doozerlib/backend/base_image_handler.py` around lines 125 - 128, The
snapshot naming can collide when multiple runs occur within the same second;
update how the timestamp is generated for custom_snapshot_name in
base_image_handler.py by using a higher-resolution UTC timestamp (include
microseconds or milliseconds) instead of get_utc_now_formatted_str() so
group_safe-{component_name}-{timestamp} becomes unique for rapid/retry runs;
modify the code that builds timestamp (or replace get_utc_now_formatted_str) to
include %f (microseconds) or an equivalent high-resolution UTC value before
constructing custom_snapshot_name that currently uses runtime.group and
component_name.

Comment on lines +187 to +202
try:
elliott_runtime = ElliottRuntime()

elliott_runtime.group = self.runtime.group
elliott_runtime.group_config = self.runtime.group_config
elliott_runtime.assembly = getattr(self.runtime, 'assembly', 'stream')
elliott_runtime.product = getattr(self.runtime, 'product', 'ocp')
elliott_runtime.dry_run = self.dry_run

release_name = await create_release_from_snapshot(
runtime=elliott_runtime,
snapshot_name=snapshot_name,
release_plan=release_plan,
namespace=self.namespace,
apply=not self.dry_run,
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for create_release_from_snapshot function definition
find . -name "*.py" -type f | xargs grep -l "def create_release_from_snapshot" 2>/dev/null | head -5

Repository: openshift-eng/art-tools

Length of output: 114


🏁 Script executed:

#!/bin/bash
# Inspect create_release_from_snapshot function implementation
sed -n '1,50p' ./elliott/elliottlib/cli/konflux_release_cli.py | head -20
# First check the imports and structure
wc -l ./elliott/elliottlib/cli/konflux_release_cli.py

Repository: openshift-eng/art-tools

Length of output: 678


🏁 Script executed:

#!/bin/bash
# Look for the create_release_from_snapshot function definition
grep -n "def create_release_from_snapshot" ./elliott/elliottlib/cli/konflux_release_cli.py

Repository: openshift-eng/art-tools

Length of output: 110


🏁 Script executed:

#!/bin/bash
# Inspect create_release_from_snapshot function implementation
sed -n '290,370p' ./elliott/elliottlib/cli/konflux_release_cli.py

Repository: openshift-eng/art-tools

Length of output: 3103


🏁 Script executed:

#!/bin/bash
# Continue reading the function to see the rest of the implementation
sed -n '370,420p' ./elliott/elliottlib/cli/konflux_release_cli.py

Repository: openshift-eng/art-tools

Length of output: 2088


🏁 Script executed:

#!/bin/bash
# Check the current code in base_image_handler.py at lines 187-202
sed -n '187,202p' doozer/doozerlib/backend/base_image_handler.py

Repository: openshift-eng/art-tools

Length of output: 752


Dry‑run still performs cluster interactions before short‑circuiting.
Although create_release_from_snapshot returns a synthetic release name when apply=False, it first executes verify_connection() and verifies snapshot existence via cluster queries. This makes dry-run unreliable and wasteful. Short‑circuit release creation in dry‑run mode with a synthetic name before calling the function.

🔧 Suggested fix
         try:
+            if self.dry_run:
+                self.logger.info(
+                    f"DRY RUN - would create release from snapshot {snapshot_name} using {release_plan}"
+                )
+                return f"dry-run-release-{snapshot_name}"
             elliott_runtime = ElliottRuntime()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
elliott_runtime = ElliottRuntime()
elliott_runtime.group = self.runtime.group
elliott_runtime.group_config = self.runtime.group_config
elliott_runtime.assembly = getattr(self.runtime, 'assembly', 'stream')
elliott_runtime.product = getattr(self.runtime, 'product', 'ocp')
elliott_runtime.dry_run = self.dry_run
release_name = await create_release_from_snapshot(
runtime=elliott_runtime,
snapshot_name=snapshot_name,
release_plan=release_plan,
namespace=self.namespace,
apply=not self.dry_run,
)
try:
if self.dry_run:
self.logger.info(
f"DRY RUN - would create release from snapshot {snapshot_name} using {release_plan}"
)
return f"dry-run-release-{snapshot_name}"
elliott_runtime = ElliottRuntime()
elliott_runtime.group = self.runtime.group
elliott_runtime.group_config = self.runtime.group_config
elliott_runtime.assembly = getattr(self.runtime, 'assembly', 'stream')
elliott_runtime.product = getattr(self.runtime, 'product', 'ocp')
elliott_runtime.dry_run = self.dry_run
release_name = await create_release_from_snapshot(
runtime=elliott_runtime,
snapshot_name=snapshot_name,
release_plan=release_plan,
namespace=self.namespace,
apply=not self.dry_run,
)
🤖 Prompt for AI Agents
In `@doozer/doozerlib/backend/base_image_handler.py` around lines 187 - 202, The
current dry-run still calls create_release_from_snapshot which performs cluster
interactions; to avoid that, short-circuit when self.dry_run is true by
constructing the synthetic release name locally instead of invoking
create_release_from_snapshot: detect dry-run (self.dry_run or set
elliott_runtime.dry_run), build the synthetic name using the same naming
convention used by create_release_from_snapshot, assign it to release_name, and
skip calling create_release_from_snapshot; keep the existing ElliottRuntime
setup (ElliottRuntime, elliott_runtime.group/assembly/product) only if needed
for name construction, otherwise avoid cluster-using helper calls entirely.

Comment on lines +992 to +1078
async def _handle_base_image_completion(self, metadata: ImageMetadata, image_pullspec: str) -> None:
"""
Handle post-build processing for base images.

Detects if the completed build is a base image and triggers the snapshot-to-release
workflow to generate dual URLs for streams.yml updates.

Args:
metadata: Image metadata for the completed build
image_pullspec: Pullspec of the built image
"""
try:
is_base_image = metadata.is_base_image()
is_snapshot_release = metadata.is_snapshot_release_enabled()

if not is_base_image:
LOGGER.info(f"Image {metadata.distgit_key} is not a base image, skipping snapshot-release workflow")
return

if not is_snapshot_release:
LOGGER.info(f"Image {metadata.distgit_key} does not have snapshot_release enabled, skipping workflow")
return

nvr = await self._extract_nvr_from_build(metadata, image_pullspec)
if not nvr:
LOGGER.warning(f"Could not extract NVR for base image {metadata.distgit_key}, skipping workflow")
return

handler = BaseImageHandler(metadata, nvr, image_pullspec, self._config.dry_run)
result = await handler.process_base_image_completion()

if result:
release_name, snapshot_name = result
LOGGER.info(f"✓ Base image workflow completed successfully for {metadata.distgit_key}")
LOGGER.info(f" Release: {release_name}")
LOGGER.info(f" Snapshot: {snapshot_name}")
else:
LOGGER.warning(f"Base image workflow failed for {metadata.distgit_key}")

except Exception as e:
LOGGER.error(f"Base image workflow error for {metadata.distgit_key}: {e}")
LOGGER.debug(f"Base image workflow traceback: {traceback.format_exc()}")

async def _extract_nvr_from_build(self, metadata: ImageMetadata, image_pullspec: str) -> Optional[str]:
"""
Extract NVR (Name-Version-Release) from the built image using image inspection.

Uses the same proven pattern as extract_parent_image_nvrs() to inspect
the built image and extract NVR from its labels.

Args:
metadata: Image metadata for the completed build
image_pullspec: Pullspec of the built image

Returns:
str: NVR string if available, None otherwise
"""
try:
# Use same pattern as extract_parent_image_nvrs()
auth_arg = f"-a {self._config.registry_auth_file}" if self._config.registry_auth_file else ""
cmd = f"oc image info -o json --filter-by-os=amd64 {auth_arg} {image_pullspec}"
rc, stdout, stderr = await exectools.cmd_gather_async(cmd, check=False)

if rc != 0:
LOGGER.warning(f"Could not access built image {image_pullspec}: {stderr}")
return None

image_info = json.loads(stdout)
labels = image_info.get('config', {}).get('config', {}).get('Labels', {})

name = labels.get('com.redhat.component')
version = labels.get('version')
release = labels.get('release')

if name and version and release:
nvr = f"{name}-{version}-{release}"
LOGGER.info(f"Extracted NVR {nvr} from built image {image_pullspec}")
return nvr
else:
LOGGER.warning(
f"Built image {image_pullspec} missing NVR labels: component={name}, version={version}, release={release}"
)
return None

except Exception as e:
LOGGER.error(f"Error extracting NVR from built image {image_pullspec}: {e}")
return None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find all calls to _handle_base_image_completion
rg "_handle_base_image_completion" -B 5 -A 2

Repository: openshift-eng/art-tools

Length of output: 1663


🏁 Script executed:

# Get more context around the call site
rg "_handle_base_image_completion" -B 30 | head -60

Repository: openshift-eng/art-tools

Length of output: 6129


🏁 Script executed:

# Get the exact line number of the call and show more context
rg -n "_handle_base_image_completion" doozer/doozerlib/backend/konflux_image_builder.py

Repository: openshift-eng/art-tools

Length of output: 270


🏁 Script executed:

# Get context around line 267 to see the broader method where the call occurs
sed -n '220,280p' doozer/doozerlib/backend/konflux_image_builder.py

Repository: openshift-eng/art-tools

Length of output: 3265


🏁 Script executed:

# Search for where NVR is computed in the codebase related to builds
rg "nvr" doozer/doozerlib/backend/konflux_image_builder.py -B 2 -A 2 | head -100

Repository: openshift-eng/art-tools

Length of output: 4034


🏁 Script executed:

# Get the full build method starting from where NVR is computed
sed -n '100,150p' doozer/doozerlib/backend/konflux_image_builder.py

Repository: openshift-eng/art-tools

Length of output: 2420


🏁 Script executed:

# Search for the method signature that contains the call to _handle_base_image_completion
sed -n '50,100p' doozer/doozerlib/backend/konflux_image_builder.py

Repository: openshift-eng/art-tools

Length of output: 1955


🏁 Script executed:

# Get the full build method to see the complete flow from NVR computation to the call
sed -n '88,280p' doozer/doozerlib/backend/konflux_image_builder.py

Repository: openshift-eng/art-tools

Length of output: 9632


🏁 Script executed:

# Let's verify the signature of _start_build to see what it does with nvr
sed -n '350,450p' doozer/doozerlib/backend/konflux_image_builder.py

Repository: openshift-eng/art-tools

Length of output: 5218


🏁 Script executed:

# Find the _start_build method signature
sed -n '500,550p' doozer/doozerlib/backend/konflux_image_builder.py

Repository: openshift-eng/art-tools

Length of output: 2362


🏁 Script executed:

# Check what happens with nvr in _start_build - does it store it somewhere accessible?
sed -n '500,650p' doozer/doozerlib/backend/konflux_image_builder.py | grep -A 50 "_start_build"

Repository: openshift-eng/art-tools

Length of output: 2362


🏁 Script executed:

# See what _start_build returns and how nvr is used in the annotations
sed -n '540,750p' doozer/doozerlib/backend/konflux_image_builder.py

Repository: openshift-eng/art-tools

Length of output: 9783


🏁 Script executed:

# Check if nvr is still in scope when _handle_base_image_completion is called (around line 267)
sed -n '240,275p' doozer/doozerlib/backend/konflux_image_builder.py

Repository: openshift-eng/art-tools

Length of output: 1950


🏁 Script executed:

# Final verification: confirm nvr is in scope at the call site by checking variable scope
sed -n '130,270p' doozer/doozerlib/backend/konflux_image_builder.py | grep -E "^\s*(nvr|if image_pullspec)"

Repository: openshift-eng/art-tools

Length of output: 192


Pass the computed NVR to _handle_base_image_completion instead of re-inspecting the built image.

The NVR is already computed from the Dockerfile in the build() method and available in scope when _handle_base_image_completion() is called. Currently, the method re-inspects the built image with oc image info and skips the snapshot-release workflow if that call fails (e.g., missing registry auth), even though the NVR is already known.

Suggested fix
@@
-    async def _handle_base_image_completion(self, metadata: ImageMetadata, image_pullspec: str) -> None:
+    async def _handle_base_image_completion(
+        self, metadata: ImageMetadata, image_pullspec: str, nvr: Optional[str] = None
+    ) -> None:
@@
-            nvr = await self._extract_nvr_from_build(metadata, image_pullspec)
+            if not nvr:
+                nvr = await self._extract_nvr_from_build(image_pullspec)
@@
-    async def _extract_nvr_from_build(self, metadata: ImageMetadata, image_pullspec: str) -> Optional[str]:
+    async def _extract_nvr_from_build(self, image_pullspec: str) -> Optional[str]:
@@
-                        await self._handle_base_image_completion(metadata, image_pullspec)
+                        await self._handle_base_image_completion(metadata, image_pullspec, nvr=nvr)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def _handle_base_image_completion(self, metadata: ImageMetadata, image_pullspec: str) -> None:
"""
Handle post-build processing for base images.
Detects if the completed build is a base image and triggers the snapshot-to-release
workflow to generate dual URLs for streams.yml updates.
Args:
metadata: Image metadata for the completed build
image_pullspec: Pullspec of the built image
"""
try:
is_base_image = metadata.is_base_image()
is_snapshot_release = metadata.is_snapshot_release_enabled()
if not is_base_image:
LOGGER.info(f"Image {metadata.distgit_key} is not a base image, skipping snapshot-release workflow")
return
if not is_snapshot_release:
LOGGER.info(f"Image {metadata.distgit_key} does not have snapshot_release enabled, skipping workflow")
return
nvr = await self._extract_nvr_from_build(metadata, image_pullspec)
if not nvr:
LOGGER.warning(f"Could not extract NVR for base image {metadata.distgit_key}, skipping workflow")
return
handler = BaseImageHandler(metadata, nvr, image_pullspec, self._config.dry_run)
result = await handler.process_base_image_completion()
if result:
release_name, snapshot_name = result
LOGGER.info(f"✓ Base image workflow completed successfully for {metadata.distgit_key}")
LOGGER.info(f" Release: {release_name}")
LOGGER.info(f" Snapshot: {snapshot_name}")
else:
LOGGER.warning(f"Base image workflow failed for {metadata.distgit_key}")
except Exception as e:
LOGGER.error(f"Base image workflow error for {metadata.distgit_key}: {e}")
LOGGER.debug(f"Base image workflow traceback: {traceback.format_exc()}")
async def _extract_nvr_from_build(self, metadata: ImageMetadata, image_pullspec: str) -> Optional[str]:
"""
Extract NVR (Name-Version-Release) from the built image using image inspection.
Uses the same proven pattern as extract_parent_image_nvrs() to inspect
the built image and extract NVR from its labels.
Args:
metadata: Image metadata for the completed build
image_pullspec: Pullspec of the built image
Returns:
str: NVR string if available, None otherwise
"""
try:
# Use same pattern as extract_parent_image_nvrs()
auth_arg = f"-a {self._config.registry_auth_file}" if self._config.registry_auth_file else ""
cmd = f"oc image info -o json --filter-by-os=amd64 {auth_arg} {image_pullspec}"
rc, stdout, stderr = await exectools.cmd_gather_async(cmd, check=False)
if rc != 0:
LOGGER.warning(f"Could not access built image {image_pullspec}: {stderr}")
return None
image_info = json.loads(stdout)
labels = image_info.get('config', {}).get('config', {}).get('Labels', {})
name = labels.get('com.redhat.component')
version = labels.get('version')
release = labels.get('release')
if name and version and release:
nvr = f"{name}-{version}-{release}"
LOGGER.info(f"Extracted NVR {nvr} from built image {image_pullspec}")
return nvr
else:
LOGGER.warning(
f"Built image {image_pullspec} missing NVR labels: component={name}, version={version}, release={release}"
)
return None
except Exception as e:
LOGGER.error(f"Error extracting NVR from built image {image_pullspec}: {e}")
return None
async def _handle_base_image_completion(
self, metadata: ImageMetadata, image_pullspec: str, nvr: Optional[str] = None
) -> None:
"""
Handle post-build processing for base images.
Detects if the completed build is a base image and triggers the snapshot-to-release
workflow to generate dual URLs for streams.yml updates.
Args:
metadata: Image metadata for the completed build
image_pullspec: Pullspec of the built image
nvr: Optional NVR (Name-Version-Release) string. If not provided, will be extracted from the built image.
"""
try:
is_base_image = metadata.is_base_image()
is_snapshot_release = metadata.is_snapshot_release_enabled()
if not is_base_image:
LOGGER.info(f"Image {metadata.distgit_key} is not a base image, skipping snapshot-release workflow")
return
if not is_snapshot_release:
LOGGER.info(f"Image {metadata.distgit_key} does not have snapshot_release enabled, skipping workflow")
return
if not nvr:
nvr = await self._extract_nvr_from_build(image_pullspec)
if not nvr:
LOGGER.warning(f"Could not extract NVR for base image {metadata.distgit_key}, skipping workflow")
return
handler = BaseImageHandler(metadata, nvr, image_pullspec, self._config.dry_run)
result = await handler.process_base_image_completion()
if result:
release_name, snapshot_name = result
LOGGER.info(f"✓ Base image workflow completed successfully for {metadata.distgit_key}")
LOGGER.info(f" Release: {release_name}")
LOGGER.info(f" Snapshot: {snapshot_name}")
else:
LOGGER.warning(f"Base image workflow failed for {metadata.distgit_key}")
except Exception as e:
LOGGER.error(f"Base image workflow error for {metadata.distgit_key}: {e}")
LOGGER.debug(f"Base image workflow traceback: {traceback.format_exc()}")
async def _extract_nvr_from_build(self, image_pullspec: str) -> Optional[str]:
"""
Extract NVR (Name-Version-Release) from the built image using image inspection.
Uses the same proven pattern as extract_parent_image_nvrs() to inspect
the built image and extract NVR from its labels.
Args:
image_pullspec: Pullspec of the built image
Returns:
str: NVR string if available, None otherwise
"""
try:
# Use same pattern as extract_parent_image_nvrs()
auth_arg = f"-a {self._config.registry_auth_file}" if self._config.registry_auth_file else ""
cmd = f"oc image info -o json --filter-by-os=amd64 {auth_arg} {image_pullspec}"
rc, stdout, stderr = await exectools.cmd_gather_async(cmd, check=False)
if rc != 0:
LOGGER.warning(f"Could not access built image {image_pullspec}: {stderr}")
return None
image_info = json.loads(stdout)
labels = image_info.get('config', {}).get('config', {}).get('Labels', {})
name = labels.get('com.redhat.component')
version = labels.get('version')
release = labels.get('release')
if name and version and release:
nvr = f"{name}-{version}-{release}"
LOGGER.info(f"Extracted NVR {nvr} from built image {image_pullspec}")
return nvr
else:
LOGGER.warning(
f"Built image {image_pullspec} missing NVR labels: component={name}, version={version}, release={release}"
)
return None
except Exception as e:
LOGGER.error(f"Error extracting NVR from built image {image_pullspec}: {e}")
return None
🧰 Tools
🪛 Ruff (0.15.0)

[warning] 1031-1031: Do not catch blind exception: Exception

(BLE001)


[warning] 1032-1032: Use logging.exception instead of logging.error

Replace with exception

(TRY400)


[warning] 1035-1035: Unused method argument: metadata

(ARG002)


[warning] 1076-1076: Do not catch blind exception: Exception

(BLE001)


[warning] 1077-1077: Use logging.exception instead of logging.error

Replace with exception

(TRY400)

🤖 Prompt for AI Agents
In `@doozer/doozerlib/backend/konflux_image_builder.py` around lines 992 - 1078,
The handler currently re-inspects the built image for NVR with
_extract_nvr_from_build and may skip workflow; instead, change the build() call
site to pass the already-computed nvr into _handle_base_image_completion (add a
new parameter like nvr: Optional[str]), update _handle_base_image_completion to
use the passed nvr directly (skip calling _extract_nvr_from_build) and only fall
back to extraction if the passed nvr is None, and remove or stop calling
_extract_nvr_from_build where no fallback is needed; ensure you pass the nvr
into BaseImageHandler(metadata, nvr, image_pullspec, ...) and update any
callers/signature uses accordingly.

Comment on lines +495 to +547
@konflux_release_cli.command("release-from-snapshot", short_help="Create Konflux release from existing snapshot")
@click.argument("snapshot_name", metavar="SNAPSHOT_NAME")
@click.option("--release-plan", required=True, help="Konflux release plan name to use")
@click.option("--apply", is_flag=True, default=False, help="Apply the release resource immediately")
@click.option(
'--konflux-namespace',
metavar='NAMESPACE',
help='The namespace to use for Konflux cluster connections. If not provided, will be auto-detected based on group.',
)
@click.pass_obj
@click_coroutine
async def release_from_snapshot_cli(
runtime: Runtime, snapshot_name: str, release_plan: str, apply: bool, konflux_namespace: str
):
"""
Create a Konflux Release resource from an existing snapshot.

This command creates a Release resource that references an existing snapshot
and uses the specified release plan to orchestrate the release process.
The release will generate both quay.io and registry.redhat.io URLs upon completion.

Examples:

Create release from snapshot (preview mode):
elliott -g openshift-4.22 release release-from-snapshot my-snapshot --release-plan ocp-art-base-images-silent-4-22-rhel9

Apply the release immediately:
elliott -g openshift-4.22 release release-from-snapshot my-snapshot --release-plan ocp-art-base-images-silent-4-22-rhel9 --apply
"""
# Initialize runtime for Konflux operations
runtime.initialize(build_system='konflux')

try:
release_name = await create_release_from_snapshot(
runtime=runtime,
snapshot_name=snapshot_name,
release_plan=release_plan,
namespace=konflux_namespace,
apply=apply,
)

if apply:
green_print(f"✓ Successfully created release: {release_name}")
LOGGER.info(
f"Monitor release progress: kubectl get release {release_name} -n {konflux_namespace or 'ocp-art-tenant'}"
)
else:
LOGGER.info("Preview completed - use --apply to create the release")

except Exception as e:
red_print(f"Failed to create release from snapshot: {e}")
LOGGER.error(f"CLI command failed: {e}")
exit(1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, locate and verify the file exists
find . -name "konflux_release_cli.py" -type f

Repository: openshift-eng/art-tools

Length of output: 114


🏁 Script executed:

# Check the specific lines mentioned in the review
wc -l elliott/elliottlib/cli/konflux_release_cli.py

Repository: openshift-eng/art-tools

Length of output: 116


🏁 Script executed:

# Examine the code around the mentioned lines
sed -n '490,550p' elliott/elliottlib/cli/konflux_release_cli.py

Repository: openshift-eng/art-tools

Length of output: 2434


🏁 Script executed:

# Search for the suggested function resolve_konflux_namespace_by_product
rg "resolve_konflux_namespace_by_product" --type py

Repository: openshift-eng/art-tools

Length of output: 3810


🏁 Script executed:

# Search for any namespace resolution patterns in the codebase
rg "ocp-art-tenant" --type py -B 2 -A 2

Repository: openshift-eng/art-tools

Length of output: 15359


🏁 Script executed:

# Look for how namespace is resolved elsewhere
rg "konflux_namespace" --type py -B 2 -A 2 | head -100

Repository: openshift-eng/art-tools

Length of output: 7286


🏁 Script executed:

# Check if the function is already imported and used in konflux_release_cli.py
grep -n "resolve_konflux_namespace_by_product" elliott/elliottlib/cli/konflux_release_cli.py

Repository: openshift-eng/art-tools

Length of output: 297


🏁 Script executed:

# Get the exact lines around 510-535 to see if the fix has been applied
sed -n '510,545p' elliott/elliottlib/cli/konflux_release_cli.py

Repository: openshift-eng/art-tools

Length of output: 1538


🏁 Script executed:

# Check the function definition to understand how it works
grep -A 10 "def resolve_konflux_namespace_by_product" artcommon/artcommonlib/util.py

Repository: openshift-eng/art-tools

Length of output: 497


🏁 Script executed:

# Verify imports at the top of the konflux_release_cli.py file
head -50 elliott/elliottlib/cli/konflux_release_cli.py | grep -E "^from|^import"

Repository: openshift-eng/art-tools

Length of output: 673


🏁 Script executed:

# Get the complete function to see if resolved_namespace is being used correctly
sed -n '465,547p' elliott/elliottlib/cli/konflux_release_cli.py

Repository: openshift-eng/art-tools

Length of output: 3292


Resolve the namespace before use to avoid incorrect hardcoding for non-OCP products.

The release_from_snapshot_cli function currently hardcodes ocp-art-tenant as a fallback when --konflux-namespace is not provided, which is incorrect for non-OCP products like OADP, MTA, etc. Call resolve_konflux_namespace_by_product(runtime.product, konflux_namespace) once after runtime initialization and use the resolved value consistently for both the create_release_from_snapshot call and the log message.

Suggested fix
    # Initialize runtime for Konflux operations
    runtime.initialize(build_system='konflux')

+   resolved_namespace = resolve_konflux_namespace_by_product(runtime.product, konflux_namespace)
    try:
        release_name = await create_release_from_snapshot(
            runtime=runtime,
            snapshot_name=snapshot_name,
            release_plan=release_plan,
-           namespace=konflux_namespace,
+           namespace=resolved_namespace,
            apply=apply,
         )

        if apply:
            green_print(f"✓ Successfully created release: {release_name}")
            LOGGER.info(
-               f"Monitor release progress: kubectl get release {release_name} -n {konflux_namespace or 'ocp-art-tenant'}"
+               f"Monitor release progress: kubectl get release {release_name} -n {resolved_namespace}"
            )
🧰 Tools
🪛 Ruff (0.15.0)

[warning] 544-544: Do not catch blind exception: Exception

(BLE001)


[warning] 546-546: Use logging.exception instead of logging.error

Replace with exception

(TRY400)

🤖 Prompt for AI Agents
In `@elliott/elliottlib/cli/konflux_release_cli.py` around lines 495 - 547, The
CLI currently hardcodes 'ocp-art-tenant' as a fallback namespace; after calling
runtime.initialize(build_system='konflux') in release_from_snapshot_cli, call
resolve_konflux_namespace_by_product(runtime.product, konflux_namespace), store
the result (e.g., resolved_namespace), pass resolved_namespace into
create_release_from_snapshot (namespace=resolved_namespace) and use
resolved_namespace in the LOGGER.info/green_print message instead of the literal
fallback; ensure any place that referenced konflux_namespace or used the
hardcoded 'ocp-art-tenant' uses the resolved_namespace consistently.

Comment on lines +203 to +211
if self.custom_name:
snapshot_name = self.custom_name
else:
group_name_safe = normalize_group_name_for_k8s(self.runtime.group)
if not group_name_safe:
raise ValueError(
f"Group name '{self.runtime.group}' produces invalid normalized name for Kubernetes snapshot"
)
snapshot_name = f"{group_name_safe}-{get_utc_now_formatted_str()}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing validation for custom snapshot name against Kubernetes naming rules.

When custom_name is provided, it bypasses the normalize_group_name_for_k8s validation that the auto-generated path uses. Invalid names (e.g., uppercase, underscores, exceeding 63 characters) will cause Kubernetes API errors at snapshot creation time.

Consider validating or normalizing the custom name:

🛡️ Proposed fix to validate custom_name
     async def new_snapshots(self, build_records: List[KonfluxRecord]) -> list[dict]:
         if self.custom_name:
-            snapshot_name = self.custom_name
+            # Validate custom name against K8s naming rules
+            if len(self.custom_name) > 63:
+                raise ValueError(f"Custom snapshot name '{self.custom_name}' exceeds 63 character limit")
+            if self.custom_name != self.custom_name.lower():
+                raise ValueError(f"Custom snapshot name '{self.custom_name}' must be lowercase")
+            import re
+            if not re.match(r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?$', self.custom_name):
+                raise ValueError(
+                    f"Custom snapshot name '{self.custom_name}' must be a valid DNS label "
+                    "(lowercase alphanumeric and hyphens, starting/ending with alphanumeric)"
+                )
+            snapshot_name = self.custom_name
         else:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if self.custom_name:
snapshot_name = self.custom_name
else:
group_name_safe = normalize_group_name_for_k8s(self.runtime.group)
if not group_name_safe:
raise ValueError(
f"Group name '{self.runtime.group}' produces invalid normalized name for Kubernetes snapshot"
)
snapshot_name = f"{group_name_safe}-{get_utc_now_formatted_str()}"
if self.custom_name:
# Validate custom name against K8s naming rules
if len(self.custom_name) > 63:
raise ValueError(f"Custom snapshot name '{self.custom_name}' exceeds 63 character limit")
if self.custom_name != self.custom_name.lower():
raise ValueError(f"Custom snapshot name '{self.custom_name}' must be lowercase")
import re
if not re.match(r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?$', self.custom_name):
raise ValueError(
f"Custom snapshot name '{self.custom_name}' must be a valid DNS label "
"(lowercase alphanumeric and hyphens, starting/ending with alphanumeric)"
)
snapshot_name = self.custom_name
else:
group_name_safe = normalize_group_name_for_k8s(self.runtime.group)
if not group_name_safe:
raise ValueError(
f"Group name '{self.runtime.group}' produces invalid normalized name for Kubernetes snapshot"
)
snapshot_name = f"{group_name_safe}-{get_utc_now_formatted_str()}"
🧰 Tools
🪛 Ruff (0.15.0)

[warning] 208-210: Avoid specifying long messages outside the exception class

(TRY003)

🤖 Prompt for AI Agents
In `@elliott/elliottlib/cli/snapshot_cli.py` around lines 203 - 211, The code uses
custom_name directly when set, skipping Kubernetes name validation done for
auto-generated names; update the branch that handles self.custom_name to
validate or normalize it with the same rules as normalize_group_name_for_k8s (or
call that function on self.custom_name), and if the result is empty or still
invalid raise a ValueError with a clear message; ensure snapshot_name is
assigned the validated/normalized value (and retain the timestamp suffix
behavior only for auto-generated names using get_utc_now_formatted_str()).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant