Repository: openshift-eng/art-tools
Pull request #1473: Use p2/p3 flags for Konflux releases
Author: login: locriandev
Labels: name: approved
description: Indicates a PR has been approved by an approver from all required OWNERS files.
name: lgtm
description: Indicates that a PR is ready to be merged.
Created: 2025-03-26T08:52:56Z | Merged: 2025-04-11T17:33:26Z



Diff:
diff --git a/doozer/doozerlib/backend/konflux_image_builder.py b/doozer/doozerlib/backend/konflux_image_builder.py
index a4c758bc1c..605e414fb6 100644
--- a/doozer/doozerlib/backend/konflux_image_builder.py
+++ b/doozer/doozerlib/backend/konflux_image_builder.py
@@ -26,6 +26,7 @@
 from doozerlib import constants
 from doozerlib.backend.build_repo import BuildRepo
 from doozerlib.backend.konflux_client import KonfluxClient
+from doozerlib.build_visibility import is_release_embargoed
 from doozerlib.image import ImageMetadata
 from doozerlib.record_logger import RecordLogger
 from doozerlib.source_resolver import SourceResolution
@@ -476,7 +477,7 @@ async def update_konflux_db(self, metadata, build_repo, pipelinerun, outcome, bu
             'release': release,
             'el_target': f'el{isolate_el_version_in_release(release)}',
             'arches': building_arches,
-            'embargoed': 'p1' in release.split('.'),
+            'embargoed': is_release_embargoed(release, 'konflux'),
             'start_time': datetime.now(tz=timezone.utc),
             'end_time': None,
             'nvr': nvr,
diff --git a/doozer/doozerlib/backend/rebaser.py b/doozer/doozerlib/backend/rebaser.py
index 72b81284b2..88a072bed2 100644
--- a/doozer/doozerlib/backend/rebaser.py
+++ b/doozer/doozerlib/backend/rebaser.py
@@ -24,6 +24,7 @@
 from artcommonlib.brew import BuildStates
 from doozerlib import constants, util
 from doozerlib.backend.build_repo import BuildRepo
+from doozerlib.build_visibility import is_release_embargoed, get_visibility_suffix, BuildVisibility
 from doozerlib.image import ImageMetadata
 from doozerlib.record_logger import RecordLogger
 from doozerlib.repos import Repos
@@ -245,7 +246,7 @@ def _rebase_dir(
             self._run_modifications(metadata=metadata, dest_dir=dest_dir, metadata_scripts_path=metadata_scripts_path)
 
         # Given an input release string, make an actual release string
-        # e.g. 4.17.0-202407241200.p? -> 4.17.0-202407241200.p0.assembly.stream.gdeadbee.el9
+        # e.g. 4.17.0-202407241200.p? -> 4.17.0-202407241200.p2.assembly.stream.gdeadbee.el9
         release = self._make_actual_release_string(metadata, input_release, private_fix, source)
         self._update_build_dir(metadata, dest_dir, source, version, release, downstream_parents, force_yum_updates,
                                image_repo, uuid_tag)
@@ -313,7 +314,7 @@ def _resolve_member_parent(self, member: str, original_parent: str, image_repo:
         else:
             if parent_metadata.private_fix is None:
                 raise IOError(
-                    f"Parent image {member} doesn't have .p0/.p1 flag determined. "
+                    f"Parent image {member} doesn't have .p? flag determined. "
                     "This indicates a bug in Doozer. Please report this issue."
                 )
             private_fix = parent_metadata.private_fix
@@ -512,10 +513,7 @@ def extract_version_release_private_fix(self, dfp: DockerfileParser) -> Tuple[Op
         prev_release = dfp.labels.get("release")
         private_fix = None
         if prev_release:
-            if util.isolate_pflag_in_release(prev_release) == 'p1':
-                private_fix = True
-            elif util.isolate_pflag_in_release(prev_release) == 'p0':
-                private_fix = False
+            private_fix = is_release_embargoed(prev_release, 'konflux')
         version = dfp.labels.get("version")
         return version, prev_release, private_fix
 
@@ -592,8 +590,10 @@ def _make_actual_release_string(self, metadata: ImageMetadata, input_release: st
         sb = io.StringIO()
         if input_release.endswith(".p?"):
             sb.write(input_release[:-3])  # strip .p?
-            pval = ".p1" if private_fix else ".p0"
-            sb.write(pval)
+            visibility = BuildVisibility.PRIVATE if private_fix else BuildVisibility.PUBLIC
+            pval = get_visibility_suffix('konflux', visibility)
+            sb.write(f'.{pval}')
+
         elif self._runtime.group_config.public_upstreams:
             raise ValueError(f"'release' must end with '.p?' for an image with a public upstream but its actual value is {input_release}")
 
diff --git a/doozer/doozerlib/build_status_detector.py b/doozer/doozerlib/build_status_detector.py
index e5a28ecf2b..96b9d2045e 100644
--- a/doozer/doozerlib/build_status_detector.py
+++ b/doozer/doozerlib/build_status_detector.py
@@ -3,6 +3,7 @@
 from typing import Dict, List, Optional, Set, Iterable
 
 from doozerlib import brew, util
+from doozerlib.build_visibility import is_release_embargoed
 
 
 class BuildStatusDetector:
@@ -32,8 +33,9 @@ def find_embargoed_builds(self, builds: List[Dict], candidate_tags: Iterable[str
         shipped_ids = self.find_shipped_builds({b["id"] for b in builds})
         suspects = [b for b in builds if b["id"] not in shipped_ids]
 
-        # next, consider remaining builds embargoed if the release field includes .p1
-        embargoed_ids = {b["id"] for b in suspects if util.isolate_pflag_in_release(b["release"]) == "p1"}
+        # next, consider remaining builds embargoed if the release field includes .p1/.p3
+        embargoed_ids = {b["id"] for b in suspects
+                         if is_release_embargoed(b["release"], self.runtime.build_system)}
 
         # finally, look at the remaining images in case they include embargoed rpms
         remaining_ids = {b["id"] for b in suspects if b["id"] not in embargoed_ids}
@@ -77,7 +79,7 @@ def find_with_embargoed_rpms(self, suspect_build_ids: Set[int], candidate_tags:
                 rpms = archive["rpms"]
                 suspected_rpms = [
                     rpm for rpm in rpms
-                    if util.isolate_pflag_in_release(rpm["release"]) == "p1"
+                    if is_release_embargoed(rpm["release"], self.runtime.build_system)
                     or rpm["build_id"] in embargoed_rpm_ids
                 ]
                 shipped = self.find_shipped_builds([rpm["build_id"] for rpm in suspected_rpms])
diff --git a/doozer/doozerlib/build_visibility.py b/doozer/doozerlib/build_visibility.py
new file mode 100644
index 0000000000..c5cb4f646a
--- /dev/null
+++ b/doozer/doozerlib/build_visibility.py
@@ -0,0 +1,80 @@
+from enum import Enum
+
+import re
+from typing import List, Optional
+
+# from doozerlib.util import isolate_pflag_in_release
+
+
+class BuildVisibility(Enum):
+    PUBLIC = 0
+    PRIVATE = 1
+
+
+VISIBILITY_SUFFIX = {
+    'brew': {
+        BuildVisibility.PUBLIC: 'p0',
+        BuildVisibility.PRIVATE: 'p1',
+    },
+    'konflux': {
+        BuildVisibility.PUBLIC: 'p2',
+        BuildVisibility.PRIVATE: 'p3',
+    }
+}
+
+
+def get_build_system(visibility_suffix: str) -> str:
+    """
+    Get the build system from the visibility suffix.
+    E.g. 'p0' -> 'brew', 'p3' -> 'konflux'
+    """
+    for build_system, visibility_map in VISIBILITY_SUFFIX.items():
+        if visibility_suffix in visibility_map.values():
+            return build_system
+    raise ValueError(f"Unknown visibility suffix: {visibility_suffix}")
+
+
+def is_release_embargoed(release: str, build_system: str) -> bool:
+    """
+    Get the embargo status from the visibility suffix.
+    E.g. 'p0' -> False, 'p3' -> True
+    """
+    pflag = isolate_pflag_in_release(release)
+    if pflag == VISIBILITY_SUFFIX[build_system][BuildVisibility.PRIVATE]:
+        return True
+    elif pflag == VISIBILITY_SUFFIX[build_system][BuildVisibility.PUBLIC]:
+        return False
+
+    # If in doubt (e.g. the p? information is missing) mark the build as embargoed
+    return True
+
+
+def get_visibility_suffix(build_system: str, visibility: BuildVisibility) -> str:
+    """
+    Get the p? flag based on the visibility status and the build system. E.g.:
+    - 'brew', PUBLIC -> 'p0'
+    - 'konflux', PRIVATE -> 'p3'
+    """
+    return VISIBILITY_SUFFIX[build_system][visibility]
+
+
+def get_all_visibility_suffixes() -> List[str]:
+    """
+    Return all valid visibility suffixes, for all build systems and visibility statuses
+    """
+    return [suffix for d in VISIBILITY_SUFFIX.values() for suffix in d.values()]
+
+
+def isolate_pflag_in_release(release: str) -> Optional[str]:
+    """
+    Given a release field, determines whether it contains any of the visibility suffixes.
+    If it does, it returns the matched suffix (e.g., 'p0', 'p1', 'p2', 'p3').
+    If not found, returns None.
+    """
+    suffixes = get_all_visibility_suffixes() + ['p?']
+    suffix_pattern = '|'.join(re.escape(suffix) for suffix in suffixes)
+    pattern = rf'\.({suffix_pattern})(?:\.|$)'
+    match = re.search(pattern, release)
+    if match:
+        return match.group(1)
+    return None
diff --git a/doozer/doozerlib/cli/config_plashet.py b/doozer/doozerlib/cli/config_plashet.py
index 56eab87dce..5d68fafcef 100644
--- a/doozer/doozerlib/cli/config_plashet.py
+++ b/doozer/doozerlib/cli/config_plashet.py
@@ -1,6 +1,7 @@
 import itertools
 import logging
 import os
+import re
 import ssl
 import sys
 import time
@@ -21,6 +22,7 @@
 from artcommonlib.rpm_utils import parse_nvr, compare_nvr
 from requests_kerberos import HTTPKerberosAuth
 
+from doozerlib.build_visibility import get_visibility_suffix, BuildVisibility
 from doozerlib.cli import cli
 from doozerlib.exceptions import DoozerFatalError
 from doozerlib.plashet import PlashetBuilder
@@ -481,7 +483,7 @@ def from_tags(config: SimpleNamespace, brew_tag: Tuple[Tuple[str, str], ...], em
     automatic sign on attach).
 
     If you specify --embargoed-brew-tag, plashet will treat any nvr found in this tag as if it is
-    embargoed (unless has already shipped). This is useful since the .p1 convention cannot be used
+    embargoed (unless has already shipped). This is useful since the .p1/p3 convention cannot be used
     on RPMs not built by ART.
 
 
@@ -586,11 +588,12 @@ def from_tags(config: SimpleNamespace, brew_tag: Tuple[Tuple[str, str], ...], em
                 released_nvre_obj = released_package_nvre_obj[package_name]
 
             def is_embargoed(an_nvre):
-                # .p1 or inclusion in the embargoed_tag_nvrs indicates this rpm is embargoed OR *was* embargoed.
+                # .p1/.p3 or inclusion in the embargoed_tag_nvrs indicates this rpm is embargoed OR *was* embargoed.
                 # We can ignore it if it has already shipped.
                 test_nvre_obj = parse_nvr(an_nvre)
                 if released_nvre_obj is None or compare_nvr(test_nvre_obj, released_nvre_obj) > 0:  # If this nvr hasn't shipped
-                    if '.p1' in an_nvre or strip_epoch(an_nvre) in embargoed_tag_nvrs:  # It's embargoed!
+                    pflag = get_visibility_suffix(runtime.build_system, BuildVisibility.PRIVATE)
+                    if f'.{pflag}' in (an_nvre or '') or strip_epoch(an_nvre) in embargoed_tag_nvrs:  # It's embargoed!
                         return True
                 return False
 
diff --git a/doozer/doozerlib/cli/images_konflux.py b/doozer/doozerlib/cli/images_konflux.py
index 331403bff4..3f7d542517 100644
--- a/doozer/doozerlib/cli/images_konflux.py
+++ b/doozer/doozerlib/cli/images_konflux.py
@@ -94,7 +94,7 @@ async def run(self):
 @click.option("--version", metavar='VERSION', required=True, callback=validate_semver_major_minor_patch,
               help="Version string to populate in Dockerfiles.")
 @click.option("--release", metavar='RELEASE', required=True, help="Release string to populate in Dockerfiles.")
-@click.option("--embargoed", is_flag=True, help="Add .p1 to the release string for all images, which indicates those images have embargoed fixes")
+@click.option("--embargoed", is_flag=True, help="Add .p3 to the release string for all images, which indicates those images have embargoed fixes")
 @click.option("--force-yum-updates", is_flag=True, default=False,
               help="Inject \"yum update -y\" in the final stage of an image build. This ensures the component image will be able to override RPMs it is inheriting from its parent image using RPMs in the rebuild plashet.")
 @click.option("--repo-type", metavar="REPO_TYPE", envvar="OIT_IMAGES_REPO_TYPE",
diff --git a/doozer/doozerlib/cli/rpms.py b/doozer/doozerlib/cli/rpms.py
index 72d23c0ace..5fcc05527c 100644
--- a/doozer/doozerlib/cli/rpms.py
+++ b/doozer/doozerlib/cli/rpms.py
@@ -14,6 +14,7 @@
 from artcommonlib.rpm_utils import parse_nvr
 from artcommonlib.util import convert_remote_git_to_https
 from doozerlib.brew import get_build_objects
+from doozerlib.build_visibility import is_release_embargoed
 from doozerlib.cli import cli, click_coroutine, pass_runtime, validate_rpm_version
 from doozerlib.exceptions import DoozerFatalError
 from doozerlib.rpm_builder import RPMBuilder
@@ -83,7 +84,7 @@ def rpms_clone_sources(runtime, output_yml):
 @click.option("--release", metavar='RELEASE', default=None,
               help="Release label to populate in specfile.", required=True)
 @click.option("--embargoed", default=False, is_flag=True,
-              help="Add .p1 to the release string for all rpms, which indicates those rpms have embargoed fixes")
+              help="Add .p1/p3 to the release string for all rpms, which indicates those rpms have embargoed fixes")
 @click.option('--scratch', default=False, is_flag=True, help='Perform a scratch build.')
 @click.option('--dry-run', default=False, is_flag=True, help='Do not build anything, but only print build operations.')
 @pass_runtime
@@ -150,7 +151,7 @@ async def _rebase_and_build(rpm: RPMMetadata):
 @click.option("--release", metavar='RELEASE', default=None,
               help="Release label to populate in specfile.", required=True)
 @click.option("--embargoed", default=False, is_flag=True,
-              help="Add .p1 to the release string for all rpms, which indicates those rpms have embargoed fixes")
+              help="Add .p1/p3 to the release string for all rpms, which indicates those rpms have embargoed fixes")
 @click.option('--dry-run', default=False, is_flag=True, help='Do not build anything, but only print build operations.')
 @click.option('--push/--no-push', default=False, is_flag=True,
               help='Push changes back to config repo. --no-push is default')
@@ -347,7 +348,7 @@ async def update_konflux_db(runtime, rpm: RPMMetadata, record: dict):
             commitish=rpm.pre_init_sha,
             rebase_repo_url=rebase_repo_url,
             rebase_commitish=rebase_commitish,
-            embargoed="p1" in rpm.release.split("."),
+            embargoed=is_release_embargoed(rpm.release, runtime.build_system),
             start_time=datetime.strptime(build["creation_time"], '%Y-%m-%d %H:%M:%S.%f'),
             end_time=datetime.strptime(build["completion_time"], '%Y-%m-%d %H:%M:%S.%f'),
             artifact_type=ArtifactType.RPM,
diff --git a/doozer/doozerlib/distgit.py b/doozer/doozerlib/distgit.py
index 36e62e3112..721d79647d 100644
--- a/doozer/doozerlib/distgit.py
+++ b/doozer/doozerlib/distgit.py
@@ -35,6 +35,8 @@
 from artcommonlib.rpm_utils import parse_nvr
 from doozerlib import state, util
 from artcommonlib.brew import BuildStates
+from doozerlib.build_visibility import is_release_embargoed, get_all_visibility_suffixes, get_visibility_suffix, \
+    BuildVisibility, isolate_pflag_in_release
 from doozerlib.dblib import Record
 from doozerlib.exceptions import DoozerFatalError
 from doozerlib.build_info import BrewBuildRecordInspector
@@ -1351,7 +1353,7 @@ def update_konflux_db(self, build_info, outcome, build_pipeline_url='', scratch=
                 'version': version,
                 'release': release,
                 'el_target': f'el{isolate_el_version_in_release(release)}',
-                'embargoed': 'p1' in release.split('.'),
+                'embargoed': is_release_embargoed(release, self.runtime.build_system),
                 'arches': self.metadata.get_arches(),
                 'source_repo': source_repo,
                 'commitish': commitish,
@@ -1630,7 +1632,7 @@ def _mapped_image_from_member(self, image, original_parent, dfp):
                     '[{}] parent image {} not included. Looking up FROM tag.'.format(self.config.name, base))
                 base_meta = self.runtime.late_resolve_image(base)
                 _, v, r = base_meta.get_latest_build_info()
-                if util.isolate_pflag_in_release(r) == 'p1':  # latest parent is embargoed
+                if is_release_embargoed(r, self.runtime.build_system):  # latest parent is embargoed
                     self.metadata.private_fix = True  # this image should also be embargoed
                 return "{}:{}-{}".format(base_meta.config.name, v, r)
             # Otherwise, the user is not expecting the FROM field to be updated in this Dockerfile.
@@ -1642,7 +1644,7 @@ def _mapped_image_from_member(self, image, original_parent, dfp):
             else:
                 if from_image_metadata.private_fix is None:  # This shouldn't happen.
                     raise ValueError(
-                        f"Parent image {base} doesn't have .p0/.p1 flag determined. "
+                        f"Parent image {base} doesn't have .p? flag determined. "
                         f"This indicates a bug in Doozer."
                     )
                 # If the parent we are going to build is embargoed, this image should also be embargoed
@@ -1979,7 +1981,7 @@ def update_distgit_dir(self, version, release, prev_release=None, force_yum_upda
                 # increment the release that was in the Dockerfile
                 if prev_release:
                     self.logger.info("Bumping release field in Dockerfile")
-                    if self.runtime.group_config.public_upstreams and util.isolate_pflag_in_release(prev_release) in ('p0', 'p1'):
+                    if self.runtime.group_config.public_upstreams and isolate_pflag_in_release(prev_release) in get_all_visibility_suffixes():
                         # We can assume .pX is a suffix because assemblies are asserted disabled earlier.
                         prev_release = prev_release[:-3]  # strip .p0/1
                     # If release has multiple fields (e.g. 0.173.0.0), increment final field
@@ -2006,14 +2008,15 @@ def update_distgit_dir(self, version, release, prev_release=None, force_yum_upda
             # generally ideal for refresh-images where the only goal is to not collide with
             # a pre-existing image version-release.
             if release is not None:
-                pval = '.p0'
+                pval = f'.{get_visibility_suffix(self.runtime.build_system, BuildVisibility.PUBLIC)}'
                 if self.runtime.group_config.public_upstreams:
                     if not release.endswith(".p?"):
                         raise ValueError(
                             f"'release' must end with '.p?' for an image with a public upstream but its actual value is {release}")
                     if self.metadata.private_fix is None:
                         raise ValueError("metadata.private_fix must be set (or determined by _merge_source) before rebasing for an image with a public upstream")
-                    pval = ".p1" if self.metadata.private_fix else ".p0"
+                    if self.metadata.private_fix:
+                        pval = f'.{get_visibility_suffix(self.runtime.build_system, BuildVisibility.PRIVATE)}'
 
                 if release.endswith(".p?"):
                     release = release[:-3]  # strip .p?
@@ -2778,10 +2781,7 @@ def extract_version_release_private_fix(self) -> Tuple[str, str, str]:
             # extract previous release to enable incrementing it
             prev_release = dfp.labels.get("release")
             if prev_release:
-                if util.isolate_pflag_in_release(prev_release) == 'p1':
-                    private_fix = True
-                elif util.isolate_pflag_in_release(prev_release) == 'p0':
-                    private_fix = False
+                private_fix = is_release_embargoed(prev_release, self.runtime.build_system)
             version = dfp.labels.get("version")
             return version, prev_release, private_fix
         return None, None, None
diff --git a/doozer/doozerlib/rpm_builder.py b/doozer/doozerlib/rpm_builder.py
index 8a82163b61..69eb372d56 100644
--- a/doozer/doozerlib/rpm_builder.py
+++ b/doozer/doozerlib/rpm_builder.py
@@ -15,6 +15,7 @@
 from artcommonlib.model import Missing
 from artcommonlib.release_util import isolate_assembly_in_release
 from doozerlib import brew
+from doozerlib.build_visibility import get_visibility_suffix, BuildVisibility
 from doozerlib.constants import BREWWEB_URL
 from doozerlib.distgit import RPMDistGitRepo
 from doozerlib.rpmcfg import RPMMetadata
@@ -64,7 +65,7 @@ async def rebase(self, rpm: RPMMetadata, version: str, release: str) -> str:
             ["git", "rm", "--ignore-unmatch", "-rf", "."], cwd=dg.distgit_dir
         )
 
-        # set .p0/.p1 flag
+        # set .p? flag
         if self._runtime.group_config.public_upstreams:
             if not release.endswith(".p?"):
                 raise ValueError(
@@ -72,11 +73,13 @@ async def rebase(self, rpm: RPMMetadata, version: str, release: str) -> str:
                 )
             if rpm.private_fix is None:
                 raise AssertionError("rpm.private_fix flag should already be set")
-            if rpm.private_fix:
+            elif rpm.private_fix:
                 logger.warning("Source contains embargoed fixes.")
-                pval = ".p1"
+                visibility = BuildVisibility.PRIVATE
             else:
-                pval = ".p0"
+                visibility = BuildVisibility.PUBLIC
+
+            pval = f'.{get_visibility_suffix(self._runtime.build_system, visibility)}'
             release = release[:-3] + pval
 
         # include commit hash in release field
diff --git a/doozer/doozerlib/util.py b/doozer/doozerlib/util.py
index de31a07a67..1699be9533 100644
--- a/doozer/doozerlib/util.py
+++ b/doozer/doozerlib/util.py
@@ -239,20 +239,6 @@ def isolate_git_commit_in_release(release: str) -> Optional[str]:
     return None
 
 
-def isolate_pflag_in_release(release: str) -> Optional[str]:
-    """
-    Given a release field, determines whether is contains
-    .p0/.p1 information. If it does, it returns the value
-    'p0' or 'p1'. If it is not found, None is returned.
-    """
-    match = re.match(r'.*\.(p[?01])(?:\.+|$)', release)
-
-    if match:
-        return match.group(1)
-
-    return None
-
-
 def isolate_nightly_name_components(nightly_name: str) -> (str, str, bool):
     """
     Given a release name (e.g. 4.8.0-0.nightly-s390x-2021-07-02-143555, 4.1.0-0.nightly-priv-2019-11-08-213727),
diff --git a/doozer/tests/test_build_status_detector.py b/doozer/tests/test_build_status_detector.py
index c70d98a6de..01d7992afa 100644
--- a/doozer/tests/test_build_status_detector.py
+++ b/doozer/tests/test_build_status_detector.py
@@ -38,7 +38,7 @@ def test_find_embargoed_builds(self):
              patch("doozerlib.brew.list_image_rpms", return_value=rpm_lists), \
              patch("doozerlib.build_status_detector.BuildStatusDetector.find_shipped_builds",
                    side_effect=lambda builds: {b for b in builds if b in shipped_builds}):
-            detector = BuildStatusDetector(MagicMock(), MagicMock())
+            detector = BuildStatusDetector(MagicMock(build_system='brew'), MagicMock())
             detector.archive_lists = archive_lists
             actual = detector.find_embargoed_builds(builds, [])
             self.assertEqual(actual, expected)
@@ -71,7 +71,7 @@ def test_find_with_embargoed_rpms(self):
                    return_value=embargoed_tag_builds), \
              patch("doozerlib.build_status_detector.BuildStatusDetector.find_shipped_builds",
                    side_effect=lambda builds: {b for b in builds if b in shipped_rpm_builds}):
-            detector = BuildStatusDetector(MagicMock(), MagicMock())
+            detector = BuildStatusDetector(MagicMock(build_system='brew'), MagicMock())
             detector.archive_lists = archive_lists
             actual = detector.find_with_embargoed_rpms(set(b["id"] for b in image_builds), ["test-candidate"])
             self.assertEqual(actual, expected)
diff --git a/doozer/tests/test_build_visibility.py b/doozer/tests/test_build_visibility.py
new file mode 100644
index 0000000000..401191f027
--- /dev/null
+++ b/doozer/tests/test_build_visibility.py
@@ -0,0 +1,66 @@
+from unittest import TestCase
+
+from doozerlib.build_visibility import get_build_system, is_release_embargoed, get_visibility_suffix, BuildVisibility, \
+    get_all_visibility_suffixes, isolate_pflag_in_release
+
+
+class TestBuildVisibility(TestCase):
+    def test_get_build_system(self):
+        self.assertEqual(get_build_system('p0'), 'brew')
+        self.assertEqual(get_build_system('p1'), 'brew')
+        self.assertEqual(get_build_system('p2'), 'konflux')
+        self.assertEqual(get_build_system('p3'), 'konflux')
+        with self.assertRaises(ValueError):
+            get_build_system('.p1')
+        with self.assertRaises(ValueError):
+            get_build_system('p5')
+
+    def test_is_embargoed(self):
+        self.assertFalse(is_release_embargoed('v4.17.0-202503120643.p0', 'brew'))
+        self.assertTrue(is_release_embargoed('v4.17.0-202503120643.p1', 'brew'))
+        self.assertFalse(is_release_embargoed('v4.17.0-202503120643.p2', 'konflux'))
+        self.assertTrue(is_release_embargoed('v4.17.0-202503120643.p3', 'konflux'))
+
+        # Build system/suffix mismatch: treat as embargo
+        self.assertTrue(is_release_embargoed('v4.17.0-202503120643.p0', 'konflux'))
+        self.assertTrue(is_release_embargoed('v4.17.0-202503120643.p2', 'brew'))
+
+        # Unmatched .p? flag: treat as embargo
+        self.assertTrue(is_release_embargoed('v4.17.0-202503120643.p5', 'brew'))
+        self.assertTrue(is_release_embargoed('v4.17.0-202503120643.p?', 'brew'))
+
+        # p? flag not found: treat as embargo
+        self.assertTrue(is_release_embargoed('v4.17.0-202503120643', 'brew'))
+
+        # Wrong build system: KeyError is raised
+        with self.assertRaises(KeyError):
+            is_release_embargoed('v4.17.0-202503120643.p0', 'wrong')
+
+    def test_get_visibility_suffix(self):
+        self.assertEqual(get_visibility_suffix('brew', BuildVisibility.PUBLIC), 'p0')
+        self.assertEqual(get_visibility_suffix('brew', BuildVisibility.PRIVATE), 'p1')
+        self.assertEqual(get_visibility_suffix('konflux', BuildVisibility.PUBLIC), 'p2')
+        self.assertEqual(get_visibility_suffix('konflux', BuildVisibility.PRIVATE), 'p3')
+        with self.assertRaises(KeyError):
+            get_visibility_suffix('invalid', BuildVisibility.PUBLIC)
+        with self.assertRaises(KeyError):
+            get_visibility_suffix('invalid', BuildVisibility.PRIVATE)
+
+    def test_get_all_visibility_suffixes(self):
+        self.assertEqual(get_all_visibility_suffixes(), ['p0', 'p1', 'p2', 'p3'])
+
+    def test_isolate_pflag(self):
+        self.assertEqual(isolate_pflag_in_release('1.2.3-y.p.p1'), 'p1')
+        self.assertEqual(isolate_pflag_in_release('1.2.3-y.p1.p'), 'p1')
+        self.assertEqual(isolate_pflag_in_release('1.2.3-y.p.p1.el7'), 'p1')
+        self.assertEqual(isolate_pflag_in_release('1.2.3-y.p.p0'), 'p0')
+        self.assertEqual(isolate_pflag_in_release('1.2.3-y.p.p2'), 'p2')
+        self.assertEqual(isolate_pflag_in_release('1.2.3-y.p.p1.assembly.p'), 'p1')
+        self.assertEqual(isolate_pflag_in_release('1.2.3-y.p.p0.assembly.test'), 'p0')
+        self.assertEqual(isolate_pflag_in_release('1.2.3-y.p.p2.assembly.stream'), 'p2')
+        self.assertEqual(isolate_pflag_in_release('1.2.3-y.p.p0.assembly.test.el7'), 'p0')
+        self.assertEqual(isolate_pflag_in_release('1.2.3-y.p.p3'), 'p3')
+        self.assertEqual(isolate_pflag_in_release('1.2.3-y.p3.p'), 'p3')
+        self.assertEqual(isolate_pflag_in_release('1.2.3-y.p4.p'), None)
+        self.assertEqual(isolate_pflag_in_release('1.2.3-y.p.p?'), 'p?')
+        self.assertEqual(isolate_pflag_in_release('1.2.3-y.p?.p4'), 'p?')
diff --git a/doozer/tests/test_rpm_builder.py b/doozer/tests/test_rpm_builder.py
index 70501310c5..f7593d99ed 100644
--- a/doozer/tests/test_rpm_builder.py
+++ b/doozer/tests/test_rpm_builder.py
@@ -17,6 +17,7 @@ def setUp(self) -> None:
 
     def _make_runtime(self, assembly=None):
         runtime = mock.MagicMock()
+        runtime.build_system = 'brew'
         runtime.group_config.public_upstreams = [{"private": "https://github.com/openshift-priv", "public": "https://github.com/openshift"}]
         runtime.brew_logs_dir = "/path/to/brew-logs"
         runtime.assembly = assembly
diff --git a/doozer/tests/test_util.py b/doozer/tests/test_util.py
index e13e94aa94..9de790bf29 100644
--- a/doozer/tests/test_util.py
+++ b/doozer/tests/test_util.py
@@ -13,16 +13,6 @@ def test_isolate_nightly_name_components(self):
         self.assertEqual(util.isolate_nightly_name_components('4.1.0-0.nightly-s390x-2019-11-08-213727'), ('4.1', 's390x', False))
         self.assertEqual(util.isolate_nightly_name_components('4.9.0-0.nightly-arm64-priv-2021-06-08-213727'), ('4.9', 'aarch64', True))
 
-    def test_isolate_pflag(self):
-        self.assertEqual(util.isolate_pflag_in_release('1.2.3-y.p.p1'), 'p1')
-        self.assertEqual(util.isolate_pflag_in_release('1.2.3-y.p.p1.el7'), 'p1')
-        self.assertEqual(util.isolate_pflag_in_release('1.2.3-y.p.p0'), 'p0')
-        self.assertEqual(util.isolate_pflag_in_release('1.2.3-y.p.p2'), None)
-        self.assertEqual(util.isolate_pflag_in_release('1.2.3-y.p.p1.assembly.p'), 'p1')
-        self.assertEqual(util.isolate_pflag_in_release('1.2.3-y.p.p0.assembly.test'), 'p0')
-        self.assertEqual(util.isolate_pflag_in_release('1.2.3-y.p.p2.assembly.stream'), None)
-        self.assertEqual(util.isolate_pflag_in_release('1.2.3-y.p.p0.assembly.test.el7'), 'p0')
-
     def test_extract_version_fields(self):
         self.assertEqual(util.extract_version_fields('1.2.3'), [1, 2, 3])
         self.assertEqual(util.extract_version_fields('1.2'), [1, 2])


Reviews:
user: login: joepvd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T09:07:29Z
user: login: thegreyd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T14:36:38Z
user: login: thegreyd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T14:49:38Z
user: login: thegreyd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T15:53:28Z

Review comments:
diff_hunk: @@ -241,18 +241,32 @@ def isolate_git_commit_in_release(release: str) -> Optional[str]:
 
 def isolate_pflag_in_release(release: str) -> Optional[str]:
     """
-    Given a release field, determines whether is contains
-    .p0/.p1 information. If it does, it returns the value
-    'p0' or 'p1'. If it is not found, None is returned.
+    Given a release field, determines whether is contains .p? information.
+    If it does, it returns the value 'p0', 'p1', 'p2' or 'p3'. If it is not found, None is returned.
     """
-    match = re.match(r'.*\.(p[?01])(?:\.+|$)', release)
+    match = re.match(r'.*\.(p[?0123])(?:\.+|$)', release)
 
     if match:
         return match.group(1)
 
     return None
 
 
+def is_private_fix(release: str, runtime) -> bool:
+    """
+    Given a release field, determine if it relates to a private fix.
+    For Brew .p0 is associatedd with public builds, .p1 with private ones.
+    For Konflux .p2 is associatedd with public builds, .p3 with private ones.
+    """
+
+    pflag = isolate_pflag_in_release(release)
+    if runtime.build_system == 'brew':
+        return pflag == 'p1'
+    if runtime.build_system == 'konflux':
+        return pflag == 'p3'
+    return False
path: doozer/doozerlib/util.py
user: login: joepvd
body: Should this default to True? Better consider it private unless proven otherwise? 
created_at: 2025-03-26T09:07:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -586,11 +588,12 @@ def from_tags(config: SimpleNamespace, brew_tag: Tuple[Tuple[str, str], ...], em
                 released_nvre_obj = released_package_nvre_obj[package_name]
 
             def is_embargoed(an_nvre):
-                # .p1 or inclusion in the embargoed_tag_nvrs indicates this rpm is embargoed OR *was* embargoed.
+                # .p1/.p3 or inclusion in the embargoed_tag_nvrs indicates this rpm is embargoed OR *was* embargoed.
                 # We can ignore it if it has already shipped.
                 test_nvre_obj = parse_nvr(an_nvre)
                 if released_nvre_obj is None or compare_nvr(test_nvre_obj, released_nvre_obj) > 0:  # If this nvr hasn't shipped
-                    if '.p1' in an_nvre or strip_epoch(an_nvre) in embargoed_tag_nvrs:  # It's embargoed!
+                    pflag = get_visibility_suffix(runtime.build_system, BuildVisibility.PRIVATE)
+                    if f'.{pflag}' in (an_nvre or '') or strip_epoch(an_nvre) in embargoed_tag_nvrs:  # It's embargoed!
path: doozer/doozerlib/cli/config_plashet.py
user: login: thegreyd
body: I think it would be nice to have a `is_nvr_embargoed` / `is_nvre_embargoed` convenience methods to make it easier for the caller to determine this
created_at: 2025-04-11T14:36:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,80 @@
+from enum import Enum
+
+import re
+from typing import List, Optional
+
+# from doozerlib.util import isolate_pflag_in_release
+
+
+class BuildVisibility(Enum):
+    PUBLIC = 0
+    PRIVATE = 1
+
+
path: doozer/doozerlib/build_visibility.py
user: login: thegreyd
body: Would like to have a note here as to why we introduced p2 / p3 . Something like 

```
Pflags were first introduced to only indicate public/private status. But as a new build-system was 
introduced in ART pipelines, this flag was repurposed to also serve as a way to differentiate between 
build-systems. In the future this flag maybe extended to indicate more of these "special" values, although 
it should be done after careful consideration.
```
created_at: 2025-04-11T14:49:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,80 @@
+from enum import Enum
+
+import re
+from typing import List, Optional
+
+# from doozerlib.util import isolate_pflag_in_release
+
+
+class BuildVisibility(Enum):
+    PUBLIC = 0
+    PRIVATE = 1
+
+
+VISIBILITY_SUFFIX = {
path: doozer/doozerlib/build_visibility.py
user: login: thegreyd
body: sorry i think i'm nitpicking a bit. but why a dict instead of another Enum, since these are related well-defined constants

```python
class PFlag(Enum):
    BREW_PUBLIC = "p0"
    BREW_PRIVATE = "p1"
    KONFLUX_PUBLIC = "p2"
    KONFLUX_PRIVATE = "p3"

    # a regular class attr to save config will misbehave, so make available via a classmethod
    @classmethod
    def get_config(cls):
        config_map = {
            "p0": ("brew", BuildVisibility.PUBLIC),
            "p1": ("brew", BuildVisibility.PRIVATE),
            "p2": ("konflux", BuildVisibility.PUBLIC),
            "p3": ("konflux", BuildVisibility.PRIVATE)
        }
        return config_map

    @property
    def build_system(self):
        key = self.value
        return self.get_config()[key][0]

    @property
    def embargoed(self):
        key = self.value
        return self.get_config()[key][1] == BuildVisibility.PRIVATE
```

This will also let us iterate over pflag values. and also let us do things like 
```
pflag = PFlag(isolate_pflag_in_release(release))
print(pflag.embargoed)
```
created_at: 2025-04-11T15:53:28Z
author_association: CONTRIBUTOR

PR comments:
user: login: joepvd
created_at: 2025-03-26T08:56:45Z
body: /approve
author_association: CONTRIBUTOR
user: login: openshift-ci[bot]
created_at: 2025-03-26T08:56:50Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/openshift-eng/art-tools/pull/1473#issuecomment-2753646921" title="Approved">joepvd</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=openshift-eng%2Fart-tools).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/openshift-eng/art-tools/blob/main/OWNERS)~~ [joepvd]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR
user: login: thegreyd
created_at: 2025-04-09T19:36:46Z
body: I have some thoughts about this. When it was p0/p1 it was easy to remember and know what means what when reading code. But with 4 values now, it can cause confusion - and it can be potentially very bad if we have such a bug..

So I would propose something like this
```python
class EmbargoStatus(Enum):
    BREW_NO = "p0"
    BREW_YES = "p1"
    KONFLUX_NO = "p2"
    KONFLUX_YES = "p3"
```

and maybe as extension we have helper methods

```python
class EmbargoStatus(Enum):
    BREW_NO = "p0"
    BREW_YES = "p1"
    KONFLUX_NO = "p2"
    KONFLUX_YES = "p3"

    _details_map = {
        "p0": ("brew", False),
        "p1": ("brew", True),
        "p2": ("konflux", False),
        "p3": ("konflux", True),
    }

    @staticmethod
    def get_build_system(status: EmbargoStatus) -> str:
        details = EmbargoStatus._details_map.get(member.value)
        return details[0]

    @staticmethod
    def is_embargoed(status: EmbargoStatus) -> bool:
        details = EmbargoStatus._details_map.get(status.value)
        return details[1]

    @staticmethod
    def get(build_system: str, is_embargoed: bool) -> 'EmbargoStatus':
        for value, (details_bs, details_embargoed) in EmbargoStatus._details_map.items():
            if details_bs == search_bs and details_embargoed == is_embargoed:
                return EmbargoStatus(value)
```
author_association: CONTRIBUTOR
user: login: openshift-ci[bot]
created_at: 2025-04-11T08:30:38Z
body: @locriandev: 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 | a368ed3224ab9f2d5fc2d500db98766785f8a59e | [link](https://prow.ci.openshift.org/view/gs/test-platform-results/pr-logs/pull/openshift-eng_art-tools/1473/pull-ci-openshift-eng-art-tools-main-security/1910609854024650752) | false | `/test security`

[Full PR test history](https://prow.ci.openshift.org/pr-history?org=openshift-eng&repo=art-tools&pr=1473). [Your PR dashboard](https://prow.ci.openshift.org/pr?query=is:pr+state:open+author:locriandev).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
author_association: CONTRIBUTOR
user: login: thegreyd
created_at: 2025-04-11T17:28:01Z
body: /lgtm
author_association: CONTRIBUTOR
user: login: thegreyd
created_at: 2025-04-11T17:28:55Z
body: Discussed with Daniele that these suggestions can be implemented as our need for them becomes clearer.
author_association: CONTRIBUTOR