diff --git a/doozer/doozerlib/lockfile_prototype/constants.py b/doozer/doozerlib/lockfile_prototype/constants.py index 2ff2010874..f7112e433a 100644 --- a/doozer/doozerlib/lockfile_prototype/constants.py +++ b/doozer/doozerlib/lockfile_prototype/constants.py @@ -20,8 +20,8 @@ MAX_REINSTALL_STRIP_RETRIES = 5 DEFAULT_PLATFORM = "linux/amd64" -SYSTEM_PYTHON = "/usr/bin/python3" -RPM_LOCKFILE_ENTRY_POINT = "from rpm_lockfile import main; main()" +RPM_LOCKFILE_IMAGE = "default-route-openshift-image-registry.apps.artc2023.pc3z.p1.openshiftapps.com/art-cd/rpm-lockfile-prototype:v0.25.0" +CONTAINER_RPMDB_CACHE_PATH = Path("/root/.cache/rpm-lockfile-prototype/rpmdbs") # Shell subshell expressions that evaluate to the current architecture ARCH_SUBSHELL_KEYWORDS = ("$(arch)", "$(uname -m)", "$(uname -p)", "$(go env GOARCH)") diff --git a/doozer/doozerlib/lockfile_prototype/resolver.py b/doozer/doozerlib/lockfile_prototype/resolver.py index 9060f8609c..87ae81d29d 100644 --- a/doozer/doozerlib/lockfile_prototype/resolver.py +++ b/doozer/doozerlib/lockfile_prototype/resolver.py @@ -1,10 +1,10 @@ """ -RPM resolution via rpm-lockfile-prototype subprocess. +RPM resolution via rpm-lockfile-prototype container. -Invokes the rpm-lockfile-prototype tool through system Python so -that python3-dnf (a system package built for the system Python) is -available. The venv Python may be a different minor version where -dnf cannot be imported. +Invokes the rpm-lockfile-prototype tool inside a podman container +so that python3-dnf and all system dependencies are self-contained. +The container image is pulled from the art-cluster internal registry +on first use if not already present locally. """ import logging @@ -19,57 +19,113 @@ from artcommonlib.exectools import cmd_gather_async from doozerlib.lockfile_prototype.constants import ( + CONTAINER_RPMDB_CACHE_PATH, DEFAULT_RPM_INFILE_NAME, DEFAULT_RPM_LOCKFILE_NAME, JENKINS_CACHE_DIR, - RPM_LOCKFILE_ENTRY_POINT, + RPM_LOCKFILE_IMAGE, RPMDB_CACHE_ERROR_PATTERNS, RPMDB_CACHE_SUBDIR, - SYSTEM_PYTHON, VALID_PKG_NAME, ) from doozerlib.lockfile_prototype.models import LockfileData, RpmsInConfig -from doozerlib.lockfile_prototype.utils import build_env class RpmResolver: """ - Invokes rpm-lockfile-prototype via system Python subprocess. + Invokes rpm-lockfile-prototype via podman container. Maintains a persistent DNF repodata cache directory across resolve() calls so that repeated runs against the same repos (common during multi-image rebases) skip redundant downloads. + Pulls the container image from the art-cluster registry on + first use if not already present locally. """ - def __init__(self, working_dir: Path, logger: logging.Logger | None = None, cache_dir: str | None = None): + def __init__( + self, + working_dir: Path | None = None, + logger: logging.Logger | None = None, + cache_dir: str | None = None, + image: str | None = None, + ): self.logger = logger or logutil.get_logger(__name__) - self._working_dir = str(working_dir) + self._working_dir = str(working_dir) if working_dir else None self._cache_dir_owner = ( None if cache_dir else TemporaryDirectory(prefix="rpm-lockfile-cache-", dir=self._working_dir) ) self._cache_path = cache_dir or self._cache_dir_owner.name + self._image = image or RPM_LOCKFILE_IMAGE - # In Jenkins, redirect the rpm-lockfile-prototype RPMDB cache to a - # persistent volume via XDG_CACHE_HOME so it survives across job runs - # and stays off the small root volume (~/.cache). - # Outside Jenkins, honour an existing XDG_CACHE_HOME if set. + # In Jenkins, redirect the RPMDB cache to a persistent volume so it + # survives across job runs and stays off the small root volume. if os.environ.get("JENKINS_HOME"): - self._xdg_cache_home: Path | None = JENKINS_CACHE_DIR + xdg_cache_home: Path = JENKINS_CACHE_DIR else: - self._xdg_cache_home = None - xdg_env = os.environ.get("XDG_CACHE_HOME") - cache_root = self._xdg_cache_home or (Path(xdg_env) if xdg_env else Path.home() / ".cache") - self._rpmdb_cache_path = cache_root / RPMDB_CACHE_SUBDIR + xdg_env = os.environ.get("XDG_CACHE_HOME") + xdg_cache_home = Path(xdg_env) if xdg_env else Path.home() / ".cache" + self._rpmdb_cache_path = xdg_cache_home / RPMDB_CACHE_SUBDIR self.logger.info("RPMDB cache path: %s", self._rpmdb_cache_path) + def _build_podman_cmd( + self, + tmpdir: str, + image_pullspec: str | None, + ) -> list[str]: + """ + Build the podman run command with volume mounts and env vars. + + Arg(s): + tmpdir (str): Host temp directory with rpms.in.yaml. + image_pullspec (str | None): Base image for rpmdb context. + Return Value(s): + list[str]: Complete podman command. + """ + cmd = ["podman", "run", "--rm"] + + # Work directory: rpms.in.yaml input and rpms.lock.yaml output + cmd.extend(["-v", f"{tmpdir}:/work:Z"]) + + # DNF repodata cache — :z (shared) so parallel resolve() calls can access + cmd.extend(["-v", f"{self._cache_path}:/cache:z"]) + cmd.extend(["-e", "RPM_LOCKFILE_PROTOTYPE_DNF_CACHE=/cache"]) + + # RPMDB cache: host path → container /root/.cache/... + self._rpmdb_cache_path.mkdir(parents=True, exist_ok=True) + cmd.extend(["-v", f"{self._rpmdb_cache_path}:{CONTAINER_RPMDB_CACHE_PATH}:z"]) + + # Mount host entitlement certs for accessing protected repos. + # Use :ro without :z — SELinux won't allow relabeling system dirs. + for host_path in ("/etc/pki/entitlement", "/etc/rhsm/ca", "/etc/pki/rpm-gpg"): + if Path(host_path).is_dir(): + cmd.extend(["-v", f"{host_path}:{host_path}:ro"]) + + # Registry auth + auth_file = os.environ.get("QUAY_AUTH_FILE") or os.environ.get("REGISTRY_AUTH_FILE") + if auth_file: + cmd.extend(["-v", f"{auth_file}:/auth/auth.json:ro,z"]) + cmd.extend(["-e", "REGISTRY_AUTH_FILE=/auth/auth.json"]) + + # Image name + cmd.append(self._image) + + # Tool arguments + if image_pullspec: + cmd.extend(["--image", image_pullspec]) + else: + cmd.append("--bare") + cmd.extend(["--outfile", "/work/" + DEFAULT_RPM_LOCKFILE_NAME, "/work/" + DEFAULT_RPM_INFILE_NAME]) + + return cmd + async def resolve( self, config: RpmsInConfig, image_pullspec: str | None = None, ) -> LockfileData: """ - Resolve RPM packages by running rpm-lockfile-prototype via - system Python as a subprocess. + Resolve RPM packages by running rpm-lockfile-prototype in a + podman container. On RPMDB corruption errors, clears the cached RPMDB for the image and retries once before raising. @@ -87,25 +143,14 @@ async def resolve( in_file.write_text(yaml.safe_dump(config.model_dump(exclude_none=True), sort_keys=False)) - cmd = [SYSTEM_PYTHON, "-c", RPM_LOCKFILE_ENTRY_POINT] - if image_pullspec: - cmd.extend(["--image", image_pullspec]) - else: - cmd.append("--bare") - cmd.extend(["--outfile", str(out_file), str(in_file)]) - - env = build_env() - env["RPM_LOCKFILE_PROTOTYPE_DNF_CACHE"] = self._cache_path - if self._xdg_cache_home: - env["XDG_CACHE_HOME"] = str(self._xdg_cache_home) - env["TMPDIR"] = self._working_dir - rc, _, stderr = await cmd_gather_async(cmd, check=False, env=env) + cmd = self._build_podman_cmd(tmpdir, image_pullspec) + rc, _, stderr = await cmd_gather_async(cmd, check=False) if rc != 0: if image_pullspec and self._is_rpmdb_corrupt(stderr): self._clear_rpmdb_cache(image_pullspec) self.logger.info("Retrying rpm-lockfile-prototype after RPMDB cache error") - rc, _, stderr = await cmd_gather_async(cmd, check=False, env=env) + rc, _, stderr = await cmd_gather_async(cmd, check=False) if rc == 0: return LockfileData.model_validate(yaml.safe_load(out_file.read_text())) error_summary = stderr.strip().rsplit("\n", 1)[-1] diff --git a/doozer/tests/lockfile_prototype/test_resolver.py b/doozer/tests/lockfile_prototype/test_resolver.py index 0faddc0974..9696fbd007 100644 --- a/doozer/tests/lockfile_prototype/test_resolver.py +++ b/doozer/tests/lockfile_prototype/test_resolver.py @@ -10,6 +10,10 @@ from unittest.mock import patch import yaml +from doozerlib.lockfile_prototype.constants import ( + DEFAULT_RPM_LOCKFILE_NAME, + RPM_LOCKFILE_IMAGE, +) from doozerlib.lockfile_prototype.models import ( LockfileData, RpmsInConfig, @@ -38,19 +42,39 @@ class TestRpmResolver(unittest.TestCase): ], } + def _mock_podman_run(self, cmd, expect_bare=False, expect_image=None, **kwargs): + """ + Helper to create a mock for podman run that writes fake lockfile output. + Returns (rc, stdout, stderr). + """ + self.assertEqual(cmd[0], "podman") + self.assertEqual(cmd[1], "run") + self.assertIn("--rm", cmd) + + if expect_bare: + self.assertIn("--bare", cmd) + self.assertNotIn("--image", cmd) + if expect_image: + img_idx = cmd.index("--image") + 1 + self.assertEqual(cmd[img_idx], expect_image) + + for i, arg in enumerate(cmd): + if arg == "-v" and ":/work:" in cmd[i + 1]: + host_tmpdir = cmd[i + 1].split(":")[0] + break + host_outfile = os.path.join(host_tmpdir, DEFAULT_RPM_LOCKFILE_NAME) + with open(host_outfile, "w") as f: + yaml.safe_dump(self.FAKE_LOCKFILE_DATA, f) + return (0, "", "") + @patch("doozerlib.lockfile_prototype.resolver.cmd_gather_async") def test_resolve_bare_mode(self, mock_gather): """ - Without image_pullspec, should pass --bare to the subprocess. + Without image_pullspec, should pass --bare. """ async def mock_cmd(cmd, **kwargs): - self.assertIn("--bare", cmd) - self.assertNotIn("--image", cmd) - outfile_idx = cmd.index("--outfile") + 1 - with open(cmd[outfile_idx], "w") as f: - yaml.safe_dump(self.FAKE_LOCKFILE_DATA, f) - return (0, "", "") + return self._mock_podman_run(cmd, expect_bare=True) mock_gather.side_effect = mock_cmd resolver = RpmResolver(working_dir=Path(tempfile.mkdtemp())) @@ -66,17 +90,11 @@ async def mock_cmd(cmd, **kwargs): @patch("doozerlib.lockfile_prototype.resolver.cmd_gather_async") def test_resolve_with_image(self, mock_gather): """ - With image_pullspec, should pass --image to the subprocess. + With image_pullspec, should pass --image. """ async def mock_cmd(cmd, **kwargs): - self.assertIn("--image", cmd) - image_idx = cmd.index("--image") + 1 - self.assertEqual(cmd[image_idx], "quay.io/test/img@sha256:abc") - outfile_idx = cmd.index("--outfile") + 1 - with open(cmd[outfile_idx], "w") as f: - yaml.safe_dump(self.FAKE_LOCKFILE_DATA, f) - return (0, "", "") + return self._mock_podman_run(cmd, expect_image="quay.io/test/img@sha256:abc") mock_gather.side_effect = mock_cmd resolver = RpmResolver(working_dir=Path(tempfile.mkdtemp())) @@ -110,18 +128,15 @@ async def mock_fail(cmd, **kwargs): self.assertIn("rpm-lockfile-prototype failed", str(ctx.exception)) @patch("doozerlib.lockfile_prototype.resolver.cmd_gather_async") - def test_resolve_uses_system_python(self, mock_gather): + def test_resolve_uses_podman(self, mock_gather): """ - Should invoke /usr/bin/python3 -c to use system Python. + Should invoke podman run, not system python. """ async def mock_cmd(cmd, **kwargs): - self.assertEqual(cmd[0], "/usr/bin/python3") - self.assertEqual(cmd[1], "-c") - outfile_idx = cmd.index("--outfile") + 1 - with open(cmd[outfile_idx], "w") as f: - yaml.safe_dump(self.FAKE_LOCKFILE_DATA, f) - return (0, "", "") + self.assertEqual(cmd[0], "podman") + self.assertEqual(cmd[1], "run") + return self._mock_podman_run(cmd, expect_bare=True) mock_gather.side_effect = mock_cmd resolver = RpmResolver(working_dir=Path(tempfile.mkdtemp())) @@ -134,19 +149,15 @@ async def mock_cmd(cmd, **kwargs): mock_gather.assert_called_once() @patch("doozerlib.lockfile_prototype.resolver.cmd_gather_async") - def test_resolve_sets_dnf_cache_env(self, mock_gather): + def test_resolve_mounts_dnf_cache(self, mock_gather): """ - RPM_LOCKFILE_PROTOTYPE_DNF_CACHE should be set in the subprocess - env and point to the same directory across multiple resolve() calls. + Should mount DNF cache and set env var. """ - captured_envs: list[dict] = [] + captured_cmds = [] async def mock_cmd(cmd, **kwargs): - captured_envs.append(dict(kwargs.get("env", {}))) - outfile_idx = cmd.index("--outfile") + 1 - with open(cmd[outfile_idx], "w") as f: - yaml.safe_dump(self.FAKE_LOCKFILE_DATA, f) - return (0, "", "") + captured_cmds.append(cmd) + return self._mock_podman_run(cmd, expect_bare=True) mock_gather.side_effect = mock_cmd resolver = RpmResolver(working_dir=Path(tempfile.mkdtemp())) @@ -156,73 +167,83 @@ async def mock_cmd(cmd, **kwargs): packages=["nfs-utils"], ) asyncio.run(resolver.resolve(config)) - asyncio.run(resolver.resolve(config)) - - self.assertEqual(len(captured_envs), 2) - cache_dir_1 = captured_envs[0]["RPM_LOCKFILE_PROTOTYPE_DNF_CACHE"] - cache_dir_2 = captured_envs[1]["RPM_LOCKFILE_PROTOTYPE_DNF_CACHE"] - self.assertEqual(cache_dir_1, cache_dir_2) - self.assertTrue(os.path.isdir(cache_dir_1)) - + cmd = captured_cmds[0] + dnf_cache_set = False + for i, arg in enumerate(cmd): + if arg == "-e" and i + 1 < len(cmd) and cmd[i + 1] == "RPM_LOCKFILE_PROTOTYPE_DNF_CACHE=/cache": + dnf_cache_set = True + self.assertTrue(dnf_cache_set) + + @patch.dict(os.environ, {"REGISTRY_AUTH_FILE": "/run/containers/auth.json"}) @patch("doozerlib.lockfile_prototype.resolver.cmd_gather_async") - def test_sets_xdg_cache_home_in_jenkins(self, mock_gather): + def test_resolve_mounts_auth_file(self, mock_gather): """ - When JENKINS_HOME is set, XDG_CACHE_HOME should point to the - persistent Jenkins cache directory. + When REGISTRY_AUTH_FILE is set, should mount it and set env var. """ - captured_envs: list[dict] = [] + captured_cmds = [] async def mock_cmd(cmd, **kwargs): - captured_envs.append(dict(kwargs.get("env", {}))) - outfile_idx = cmd.index("--outfile") + 1 - with open(cmd[outfile_idx], "w") as f: - yaml.safe_dump(self.FAKE_LOCKFILE_DATA, f) - return (0, "", "") + captured_cmds.append(list(cmd)) + return self._mock_podman_run(cmd, expect_bare=True) mock_gather.side_effect = mock_cmd - with patch.dict(os.environ, {"JENKINS_HOME": "/var/jenkins"}): - resolver = RpmResolver(working_dir=Path(tempfile.mkdtemp())) - config = RpmsInConfig( - arches=["x86_64"], - contentOrigin={"repos": []}, - packages=["nfs-utils"], - ) - asyncio.run(resolver.resolve(config)) + resolver = RpmResolver() + config = RpmsInConfig( + arches=["x86_64"], + contentOrigin={"repos": []}, + packages=[], + ) + asyncio.run(resolver.resolve(config)) + cmd = captured_cmds[0] + auth_mount = "/run/containers/auth.json:/auth/auth.json:ro,z" + self.assertTrue( + any(arg == "-v" and cmd[i + 1] == auth_mount for i, arg in enumerate(cmd) if i + 1 < len(cmd)), + f"Expected auth file mount {auth_mount} in command", + ) + auth_env_set = any( + arg == "-e" and i + 1 < len(cmd) and cmd[i + 1] == "REGISTRY_AUTH_FILE=/auth/auth.json" + for i, arg in enumerate(cmd) + ) + self.assertTrue(auth_env_set) - self.assertEqual(len(captured_envs), 1) - xdg = captured_envs[0]["XDG_CACHE_HOME"] - self.assertEqual(xdg, "/mnt/jenkins-workspace/rpm-lockfile-cache") + def test_custom_image_parameter(self): + """ + Image parameter should override default. + """ + resolver = RpmResolver(image="quay.io/custom/rpm-lockfile:v1.0") + self.assertEqual(resolver._image, "quay.io/custom/rpm-lockfile:v1.0") - @patch("doozerlib.lockfile_prototype.resolver.cmd_gather_async") - def test_no_xdg_cache_home_outside_jenkins(self, mock_gather): + def test_default_image(self): """ - When JENKINS_HOME is not set, XDG_CACHE_HOME should not be - overridden in the subprocess env. + Default image should match constant. """ - captured_envs: list[dict] = [] + resolver = RpmResolver() + self.assertEqual(resolver._image, RPM_LOCKFILE_IMAGE) - async def mock_cmd(cmd, **kwargs): - captured_envs.append(dict(kwargs.get("env", {}))) - outfile_idx = cmd.index("--outfile") + 1 - with open(cmd[outfile_idx], "w") as f: - yaml.safe_dump(self.FAKE_LOCKFILE_DATA, f) - return (0, "", "") + def test_rpmdb_cache_path_in_jenkins(self): + """ + When JENKINS_HOME is set, _rpmdb_cache_path should use the + persistent Jenkins cache directory. + """ + with patch.dict(os.environ, {"JENKINS_HOME": "/var/jenkins"}): + resolver = RpmResolver(working_dir=Path(tempfile.mkdtemp())) + self.assertEqual( + str(resolver._rpmdb_cache_path), + "/mnt/jenkins-workspace/rpm-lockfile-cache/rpm-lockfile-prototype/rpmdbs", + ) - mock_gather.side_effect = mock_cmd - with patch.dict(os.environ, {}, clear=False): - env = os.environ.copy() - env.pop("JENKINS_HOME", None) - with patch.dict(os.environ, env, clear=True): - resolver = RpmResolver(working_dir=Path(tempfile.mkdtemp())) - config = RpmsInConfig( - arches=["x86_64"], - contentOrigin={"repos": []}, - packages=["nfs-utils"], - ) - asyncio.run(resolver.resolve(config)) - - self.assertEqual(len(captured_envs), 1) - self.assertNotIn("XDG_CACHE_HOME", captured_envs[0]) + def test_rpmdb_cache_path_outside_jenkins(self): + """ + When JENKINS_HOME is not set, _rpmdb_cache_path should fall + back to ~/.cache. + """ + env = os.environ.copy() + env.pop("JENKINS_HOME", None) + env.pop("XDG_CACHE_HOME", None) + with patch.dict(os.environ, env, clear=True): + resolver = RpmResolver(working_dir=Path(tempfile.mkdtemp())) + expected = Path.home() / ".cache" / "rpm-lockfile-prototype" / "rpmdbs" + self.assertEqual(resolver._rpmdb_cache_path, expected) class TestParseMissingPackages(unittest.TestCase): @@ -406,8 +427,12 @@ async def mock_cmd(cmd, **kwargs): call_count += 1 if call_count == 1: return (1, "", self.CORRUPTION_STDERR) - outfile_idx = cmd.index("--outfile") + 1 - with open(cmd[outfile_idx], "w") as f: + for i, arg in enumerate(cmd): + if arg == "-v" and ":/work:" in cmd[i + 1]: + host_tmpdir = cmd[i + 1].split(":")[0] + break + host_outfile = os.path.join(host_tmpdir, DEFAULT_RPM_LOCKFILE_NAME) + with open(host_outfile, "w") as f: yaml.safe_dump(self.FAKE_LOCKFILE_DATA, f) return (0, "", "") @@ -444,8 +469,12 @@ async def mock_cmd(cmd, **kwargs): call_count += 1 if call_count == 1: return (1, "", cache_race_stderr) - outfile_idx = cmd.index("--outfile") + 1 - with open(cmd[outfile_idx], "w") as f: + for i, arg in enumerate(cmd): + if arg == "-v" and ":/work:" in cmd[i + 1]: + host_tmpdir = cmd[i + 1].split(":")[0] + break + host_outfile = os.path.join(host_tmpdir, DEFAULT_RPM_LOCKFILE_NAME) + with open(host_outfile, "w") as f: yaml.safe_dump(self.FAKE_LOCKFILE_DATA, f) return (0, "", "")