@@ -436,6 +436,7 @@ async def _resolve_all_stages(
436436 )
437437
438438 reinstall_pkgs : list [str ] | None = None
439+ strippable : set [str ] | None = None
439440 if stage_num == final_stage_num and not is_update_only and not has_bare_update :
440441 if image_pullspec :
441442 # --image mode: pass base image packages as reinstallPackages
@@ -451,6 +452,7 @@ async def _resolve_all_stages(
451452 base_pkgs = await self ._get_base_image_packages (stage_num , image_pullspec , distgit_key )
452453 if base_pkgs :
453454 reinstall_pkgs = list (base_pkgs )
455+ strippable = set (base_pkgs ) - set (packages ) - set (upgrade_targets )
454456 self .logger .info (
455457 f"{ distgit_key } : stage { stage_num } : { len (reinstall_pkgs )} base image "
456458 "packages will be reinstalled into lockfile"
@@ -463,7 +465,24 @@ async def _resolve_all_stages(
463465 if base_pkgs :
464466 extra = [p for p in base_pkgs if p not in packages ]
465467 if extra :
468+ strippable = set (extra )
466469 packages = packages + extra
470+ elif stage_num != final_stage_num and not is_update_only :
471+ # Non-final stage (builder): reinstall the Dockerfile packages
472+ # so they appear in the lockfile even when already installed
473+ # on some architectures, and add base image packages to the
474+ # install list for conflict detection.
475+ if image_pullspec :
476+ reinstall_pkgs = list (packages )
477+ base_pkgs = await self ._get_base_image_packages (stage_num , image_pullspec , distgit_key )
478+ if base_pkgs :
479+ extra = [p for p in base_pkgs if p not in packages ]
480+ if extra :
481+ packages = packages + extra
482+ self .logger .info (
483+ f"{ distgit_key } : stage { stage_num } : { len (extra )} base image "
484+ "packages added to install list for conflict detection"
485+ )
467486
468487 enable_only = [s .split ("/" )[0 ] for s in stage_info .module_specs ] if stage_info .module_specs else None
469488
@@ -478,6 +497,7 @@ async def _resolve_all_stages(
478497 stage_num ,
479498 module_enable = enable_only ,
480499 reinstall_packages = reinstall_pkgs ,
500+ strippable_packages = strippable ,
481501 )
482502 if result :
483503 stage_lockfiles .append (result )
@@ -663,10 +683,18 @@ async def _resolve_stage_with_retry(
663683 stage_num : int ,
664684 module_enable : list [str ] | None = None ,
665685 reinstall_packages : list [str ] | None = None ,
686+ strippable_packages : set [str ] | None = None ,
666687 ) -> LockfileData | None :
667688 """
668689 Resolve a single stage, retrying after removing unavailable packages.
669690
691+ Arg(s):
692+ strippable_packages (set[str] | None): Packages that may be
693+ silently removed during retries (e.g. base image packages
694+ added for conflict detection). If a missing package is NOT
695+ in this set, it is a required Dockerfile package and the
696+ error is raised immediately.
697+
670698 Return Value(s):
671699 LockfileData | None: Lockfile data, or None if all packages filtered out.
672700 """
@@ -679,15 +707,23 @@ async def _resolve_stage_with_retry(
679707 # use the no-auth registry proxy instead.
680708 resolver_pullspec = ContainerImageHelper ._proxy_pullspec (image_pullspec ) if image_pullspec else None
681709
682- # When reinstall_packages is set, also pass them as upgrade targets.
683- # base.reinstall() raises PackagesNotAvailableError when the installed
684- # version isn't in the configured repos — but rpm-lockfile-prototype
685- # swallows that error when the package is also in upgradePackages
686- # (the upgrade provides a replacement version).
710+ # When reinstall_packages comes from the base image (final stage),
711+ # also pass them as upgrade targets. base.reinstall() raises
712+ # PackagesNotAvailableError when the installed version isn't in
713+ # the configured repos — but rpm-lockfile-prototype swallows that
714+ # error when the package is also in upgradePackages (the upgrade
715+ # provides a replacement version).
716+ # For builder stages (strippable_packages is None), reinstall
717+ # packages are Dockerfile packages that may not be installed in
718+ # the base image — adding them to upgradePackages would cause
719+ # PackagesNotInstalledError.
687720 remaining_reinstall = list (reinstall_packages ) if reinstall_packages else []
721+ promote_reinstall_to_upgrade = strippable_packages is not None
722+ retries_exhausted = False
688723
689- for attempt in range (MAX_RESOLUTION_RETRIES ):
690- effective_upgrade = list (set (remaining_update_targets + remaining_reinstall )) if image_pullspec else None
724+ for _attempt in range (MAX_RESOLUTION_RETRIES ):
725+ upgrade_extras = remaining_reinstall if promote_reinstall_to_upgrade else []
726+ effective_upgrade = list (set (remaining_update_targets + upgrade_extras )) if image_pullspec else None
691727 in_yaml = build_rpms_in_yaml (
692728 repo_list ,
693729 arches ,
@@ -699,11 +735,23 @@ async def _resolve_stage_with_retry(
699735 )
700736
701737 try :
738+ mode = "image" if resolver_pullspec else "bare"
739+ self .logger .info (
740+ f"{ distgit_key } : stage { stage_num } : resolving { len (remaining_packages )} packages in { mode } mode"
741+ )
742+ self .logger .debug (f"{ distgit_key } : stage { stage_num } : full package list: { remaining_packages } " )
702743 return await self ._resolver .resolve (in_yaml , image_pullspec = resolver_pullspec )
703744 except RuntimeError as e :
704745 missing = RpmResolver .parse_missing_packages (str (e ))
705746 if not missing :
706747 raise
748+ if strippable_packages is not None :
749+ required_missing = missing - strippable_packages
750+ if required_missing :
751+ raise RuntimeError (
752+ f"{ distgit_key } : stage { stage_num } : required packages not available "
753+ f"in configured repos: { sorted (required_missing )} "
754+ ) from e
707755 prev_count = (
708756 len (remaining_packages )
709757 + sum (len (v ) for v in arch_pkgs .values ())
@@ -733,7 +781,44 @@ async def _resolve_stage_with_retry(
733781 f"{ distgit_key } : stage { stage_num } : no packages remaining after filtering, skipping"
734782 )
735783 return None
736- raise RuntimeError (f"{ distgit_key } : stage { stage_num } : exceeded { MAX_RESOLUTION_RETRIES } resolution retries" )
784+ if strippable_packages is not None :
785+ required_reinstall = [p for p in remaining_reinstall if p not in strippable_packages ]
786+ if not required_reinstall and remaining_reinstall :
787+ self .logger .info (
788+ f"{ distgit_key } : stage { stage_num } : all { len (remaining_reinstall )} "
789+ "remaining reinstall packages are optional, skipping retries"
790+ )
791+ break
792+ else :
793+ retries_exhausted = True
794+ if strippable_packages is not None :
795+ required_pkgs = set (remaining_packages ) - strippable_packages
796+ dropped = [p for p in remaining_reinstall if p not in required_pkgs ]
797+ remaining_reinstall = [p for p in remaining_reinstall if p in required_pkgs ]
798+ if retries_exhausted :
799+ self .logger .warning (
800+ f"{ distgit_key } : stage { stage_num } : exceeded { MAX_RESOLUTION_RETRIES } retries, "
801+ f"dropped { len (dropped )} optional reinstall packages, "
802+ f"keeping { len (remaining_reinstall )} required"
803+ )
804+ else :
805+ self .logger .warning (
806+ f"{ distgit_key } : stage { stage_num } : exceeded { MAX_RESOLUTION_RETRIES } retries, "
807+ "continuing without reinstall packages"
808+ )
809+ remaining_reinstall .clear ()
810+ fallback_upgrade_extras = remaining_reinstall if promote_reinstall_to_upgrade else []
811+ effective_upgrade = list (set (remaining_update_targets + fallback_upgrade_extras )) if image_pullspec else None
812+ in_yaml = build_rpms_in_yaml (
813+ repo_list ,
814+ arches ,
815+ remaining_packages ,
816+ arch_specific_packages = arch_pkgs ,
817+ reinstall_packages = remaining_reinstall if image_pullspec else None ,
818+ upgrade_packages = effective_upgrade ,
819+ module_enable = module_enable ,
820+ )
821+ return await self ._resolver .resolve (in_yaml , image_pullspec = resolver_pullspec )
737822
738823 def _assemble_lockfile (self , stage_lockfiles : list [LockfileData ], image_meta : ImageMetadata ) -> LockfileData :
739824 """
@@ -831,6 +916,7 @@ async def _resolve_with_reconciliation(
831916 stage_num : int ,
832917 module_enable : list [str ] | None = None ,
833918 reinstall_packages : list [str ] | None = None ,
919+ strippable_packages : set [str ] | None = None ,
834920 ) -> LockfileData | None :
835921 """
836922 Resolve a stage with cross-arch version reconciliation.
@@ -851,6 +937,8 @@ async def _resolve_with_reconciliation(
851937 module_enable (list[str] | None): Module streams to enable.
852938 reinstall_packages (list[str] | None): Base image packages to
853939 reinstall from repos into the lockfile.
940+ strippable_packages (set[str] | None): Packages that may be
941+ silently removed during retries (conflict detection packages).
854942 Return Value(s):
855943 LockfileData | None: Resolved lockfile with consistent
856944 versions, or None if no packages remain.
@@ -866,6 +954,7 @@ async def _resolve_with_reconciliation(
866954 stage_num ,
867955 module_enable = module_enable ,
868956 reinstall_packages = reinstall_packages ,
957+ strippable_packages = strippable_packages ,
869958 )
870959 if not first_pass :
871960 return None
@@ -885,19 +974,25 @@ async def _resolve_with_reconciliation(
885974 )
886975
887976 pinned_packages = list (packages ) + version_pins
977+ pinned_names = set (mismatches .keys ())
978+ pinned_update_targets = [t for t in update_targets if t not in pinned_names ]
979+ pinned_reinstall = (
980+ [p for p in reinstall_packages if p not in pinned_names ] if reinstall_packages else reinstall_packages
981+ )
888982
889983 try :
890984 second_pass = await self ._resolve_stage_with_retry (
891985 repo_list ,
892986 arches ,
893987 pinned_packages ,
894988 arch_pkgs ,
895- update_targets ,
989+ pinned_update_targets ,
896990 image_pullspec ,
897991 distgit_key ,
898992 stage_num ,
899993 module_enable = module_enable ,
900- reinstall_packages = reinstall_packages ,
994+ reinstall_packages = pinned_reinstall ,
995+ strippable_packages = strippable_packages ,
901996 )
902997 except RuntimeError :
903998 self .logger .warning (
0 commit comments