Skip to content

Commit 2ecd07d

Browse files
committed
fix: detect stale bundles when operand images change in LP assemblies
When an operand image is swapped in an LP assembly, the existing bundle still references the old image. The pipeline was reusing these stale bundles because it only checked operator NVR existence, not operand content. Now find-builds returns each bundle's operand_nvrs, and the pipeline validates them against the assembly's pins before reusing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED
1 parent 464f68e commit 2ecd07d

3 files changed

Lines changed: 109 additions & 0 deletions

File tree

elliott/elliottlib/cli/find_builds_cli.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -982,5 +982,8 @@ async def find_builds_konflux_all_types(runtime: Runtime, include_shipped: bool
982982
'olm_builds': sorted([b.nvr for b in olm_records]),
983983
'olm_builds_not_found': sorted([b.nvr for b in olm_records_not_found]),
984984
'olm_operator_nvrs': sorted([b.nvr for b in operator_builds]),
985+
'olm_builds_detail': {
986+
b.nvr: {'operator_nvr': b.operator_nvr, 'operand_nvrs': b.operand_nvrs or []} for b in olm_records
987+
},
985988
}
986989
return builds_tuple

pyartcd/pyartcd/pipelines/prepare_release_lp.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ async def _find_existing_bundles(self) -> Dict[str, List[str]]:
273273
"metadata": out.get("olm_builds", []),
274274
"olm_builds_not_found": out.get("olm_builds_not_found", []),
275275
"olm_operator_nvrs": out.get("olm_operator_nvrs", []),
276+
"olm_builds_detail": out.get("olm_builds_detail", {}),
276277
}
277278
self._logger.info(
278279
"Found %d existing bundle NVRs; %d operators still need bundles",
@@ -326,6 +327,37 @@ async def _trigger_bundle_build(self, operand_nvrs: List[str]) -> Tuple[List[str
326327
self._logger.info("Skipping %d operator(s) not pinned in assembly: %s", len(skipped), skipped)
327328
missing_operator_nvrs = scoped
328329

330+
# Detect stale bundles whose operands no longer match the assembly pins.
331+
# When an operand image is swapped in the assembly, existing bundles
332+
# still reference the old image and must be rebuilt.
333+
builds_detail = existing.get("olm_builds_detail", {})
334+
if operand_nvrs and existing_bundle_nvrs and builds_detail:
335+
pinned_by_name = {parse_nvr(nvr)['name']: nvr for nvr in operand_nvrs}
336+
stale_bundles = []
337+
for bundle_nvr in existing_bundle_nvrs:
338+
detail = builds_detail.get(bundle_nvr, {})
339+
for bop_nvr in detail.get('operand_nvrs', []):
340+
bop_name = parse_nvr(bop_nvr)['name']
341+
if bop_name in pinned_by_name and pinned_by_name[bop_name] != bop_nvr:
342+
self._logger.info(
343+
"Bundle %s is stale: references %s but assembly pins %s",
344+
bundle_nvr,
345+
bop_nvr,
346+
pinned_by_name[bop_name],
347+
)
348+
stale_bundles.append(bundle_nvr)
349+
operator_nvr = detail.get('operator_nvr')
350+
if operator_nvr and operator_nvr not in missing_operator_nvrs:
351+
missing_operator_nvrs.append(operator_nvr)
352+
break
353+
if stale_bundles:
354+
existing_bundle_nvrs = [nvr for nvr in existing_bundle_nvrs if nvr not in stale_bundles]
355+
self._logger.info(
356+
"Removed %d stale bundle(s); %d operator(s) need fresh bundles",
357+
len(stale_bundles),
358+
len(missing_operator_nvrs),
359+
)
360+
329361
if existing_bundle_nvrs:
330362
self._logger.info(
331363
"Reusing %d existing bundle NVRs: %s",

pyartcd/tests/pipelines/test_prepare_release_lp.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,80 @@ def test_all_operators_filtered_skips_build(self, mock_find):
347347
self.assertEqual(bundle_nvrs, ["existing-bundle-1"])
348348
self.assertEqual(operator_nvrs, [])
349349

350+
@patch.object(PrepareReleaseLPPipeline, '_find_existing_bundles', new_callable=AsyncMock)
351+
@patch('pyartcd.pipelines.prepare_release_lp.exectools.cmd_gather_async', new_callable=AsyncMock)
352+
def test_stale_bundle_triggers_rebuild(self, mock_cmd, mock_find):
353+
"""Bundles referencing old operand images must be rebuilt when the assembly pins a new version."""
354+
mock_find.return_value = {
355+
"metadata": [
356+
"loki-rhel9-operator-metadata-container-6.0.15-1.assembly.stream.el9-1",
357+
],
358+
"olm_builds_not_found": [],
359+
"olm_operator_nvrs": [
360+
"loki-rhel9-operator-container-6.0.15-1.assembly.stream.el9",
361+
],
362+
"olm_builds_detail": {
363+
"loki-rhel9-operator-metadata-container-6.0.15-1.assembly.stream.el9-1": {
364+
"operator_nvr": "loki-rhel9-operator-container-6.0.15-1.assembly.stream.el9",
365+
"operand_nvrs": [
366+
"logging-loki-rhel9-container-6.0.15-OLD.assembly.stream.el9",
367+
],
368+
},
369+
},
370+
}
371+
mock_cmd.return_value = (
372+
0,
373+
json.dumps({"nvrs": ["loki-rhel9-operator-metadata-container-6.0.15-2"], "errors": []}),
374+
"",
375+
)
376+
377+
pipeline = self._make_pipeline()
378+
operand_nvrs = [
379+
"loki-rhel9-operator-container-6.0.15-1.assembly.stream.el9",
380+
"logging-loki-rhel9-container-6.0.15-NEW.assembly.stream.el9",
381+
]
382+
bundle_nvrs, operator_nvrs = asyncio.run(pipeline._trigger_bundle_build(operand_nvrs))
383+
384+
# The stale bundle should have been removed and a rebuild triggered
385+
mock_cmd.assert_called_once()
386+
cmd = mock_cmd.call_args[0][0]
387+
self.assertIn("loki-rhel9-operator-container-6.0.15-1.assembly.stream.el9", cmd)
388+
self.assertIn("loki-rhel9-operator-metadata-container-6.0.15-2", bundle_nvrs)
389+
# The old stale bundle should not be in the result
390+
self.assertNotIn("loki-rhel9-operator-metadata-container-6.0.15-1.assembly.stream.el9-1", bundle_nvrs)
391+
392+
@patch.object(PrepareReleaseLPPipeline, '_find_existing_bundles', new_callable=AsyncMock)
393+
def test_matching_operands_reuses_bundle(self, mock_find):
394+
"""Bundles whose operands match the assembly pins should be reused."""
395+
mock_find.return_value = {
396+
"metadata": [
397+
"loki-rhel9-operator-metadata-container-6.0.15-1.assembly.stream.el9-1",
398+
],
399+
"olm_builds_not_found": [],
400+
"olm_operator_nvrs": [
401+
"loki-rhel9-operator-container-6.0.15-1.assembly.stream.el9",
402+
],
403+
"olm_builds_detail": {
404+
"loki-rhel9-operator-metadata-container-6.0.15-1.assembly.stream.el9-1": {
405+
"operator_nvr": "loki-rhel9-operator-container-6.0.15-1.assembly.stream.el9",
406+
"operand_nvrs": [
407+
"logging-loki-rhel9-container-6.0.15-SAME.assembly.stream.el9",
408+
],
409+
},
410+
},
411+
}
412+
413+
pipeline = self._make_pipeline()
414+
operand_nvrs = [
415+
"loki-rhel9-operator-container-6.0.15-1.assembly.stream.el9",
416+
"logging-loki-rhel9-container-6.0.15-SAME.assembly.stream.el9",
417+
]
418+
bundle_nvrs, operator_nvrs = asyncio.run(pipeline._trigger_bundle_build(operand_nvrs))
419+
420+
# The bundle should be reused since operands match
421+
self.assertEqual(bundle_nvrs, ["loki-rhel9-operator-metadata-container-6.0.15-1.assembly.stream.el9-1"])
422+
self.assertEqual(operator_nvrs, ["loki-rhel9-operator-container-6.0.15-1.assembly.stream.el9"])
423+
350424

351425
class TestPrepareReleaseLPRun(unittest.TestCase):
352426
"""Integration-level tests for the run() method with mocked externals."""

0 commit comments

Comments
 (0)