feat(konflux): implement base image snapshot-to-release workflow#2476
feat(konflux): implement base image snapshot-to-release workflow#2476lgarciaaco wants to merge 1 commit intoopenshift-eng:mainfrom
Conversation
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
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
WalkthroughThis 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
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes ✨ Finishing touches
🧪 Generate unit tests (beta)
Tip Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord. Comment |
|
@lgarciaaco: The following test failed, say
Full PR test history. Your PR dashboard. DetailsInstructions 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. |
There was a problem hiding this comment.
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 | 🟠 MajorPotential name collision when custom name is used with multiple applications.
When
custom_nameis 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 usestr | Nonefor optional parameter.The static analysis correctly flags that
custom_name: str = Noneshould becustom_name: str | None = Noneper 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 enforcingsnapshot_releaseonly for base images.
The description says it applies only tobase_onlyimages, but the schema currently allows it everywhere. A smallif/thenguard 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
| timestamp = get_utc_now_formatted_str() | ||
| group_safe = self.runtime.group.replace('openshift-', '').replace('.', '-') | ||
| custom_snapshot_name = f"{group_safe}-{component_name}-{timestamp}" | ||
|
|
There was a problem hiding this comment.
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.
| 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, | ||
| ) |
There was a problem hiding this comment.
🧩 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 -5Repository: 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.pyRepository: 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.pyRepository: 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.pyRepository: 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.pyRepository: 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.pyRepository: 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.
| 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.
| 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 |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find all calls to _handle_base_image_completion
rg "_handle_base_image_completion" -B 5 -A 2Repository: 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 -60Repository: 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.pyRepository: 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.pyRepository: 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 -100Repository: 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.pyRepository: 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.pyRepository: 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.pyRepository: 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.pyRepository: 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.pyRepository: 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.pyRepository: 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.pyRepository: 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.
| 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.
| @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) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, locate and verify the file exists
find . -name "konflux_release_cli.py" -type fRepository: 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.pyRepository: 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.pyRepository: 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 pyRepository: 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 2Repository: 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 -100Repository: 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.pyRepository: 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.pyRepository: 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.pyRepository: 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.pyRepository: 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.
| 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()}" |
There was a problem hiding this comment.
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.
| 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()).
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