diff --git a/README.md b/README.md
index 23ee55f..cffb17c 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,22 @@ An alternative mode allows simply testing the consequences of *removing* a list
entirely; in this mode, in the second step, we exclude all binary packages built from the specified
source packages. The installability check is skipped in this context.
+## rdc-el-wrapper
+
+rdc-el-wrapper is an opinionated wrapper for running rmdepcheck on Enterprise Linux. The EL case
+is complicated because EL composes contain multiple variants with package repositories. These
+contain a subset of all packages in the buildroot. Each variant is expected to be repoclosure-
+complete with regard to itself and possibly some or all of the other variants; the mapping for
+this is kept in Pungi configuration.
+
+Because of this we cannot just test the packages-under-test against the buildroot, as we do for
+Fedora. We must split the packages-under-test set into per-variant repositories, with each package
+included in each repository it should be part of (we assume this mapping is not changed from the
+compose we're testing against, and discover it from that). We then test each of the split repos
+against the same variant repo from the compose, with other variant repos in scope on both sides,
+as per the Pungi mapping. This is a lot of work but should provide the most accurate and useful
+results.
+
## Requirements
rmdepcheck has no run-time Python dependencies outside the standard library. Its only external
@@ -46,8 +62,8 @@ Simple usage looks like this:
rmdepcheck https://a.base.repo.example/repo,file:///another/baserepo file:///the/testedrepo
```
-The to-be-modified base repositories are specified as a comma-separated list. Repositories are
-always specified as URLs. Only file:// , http:// and https:// URLs are accepted.
+The repositories are specified as a comma-separated list. Repositories should be specified as URLs,
+but for convenience, file:// can be omitted. Only file:// , http:// and https:// URLs are accepted.
For the alternative 'removal' mode, usage looks like:
```
@@ -66,6 +82,15 @@ Note rmdepcheck is really only intended for use as a script, not as an importabl
want to use it as a library go ahead, but this isn't a supported use case and bugs in it may not
be addressed.
+rdc-el-wrapper usage looks like this:
+```
+rdc-el-wrapper https://kojipkgs.fedoraproject.org/compose/eln/latest-Fedora-eln/compose /the/testedrepo
+```
+
+An optional `--arch` arg can be passed as for rmdepcheck. Note that you **MUST NOT** include
+file:// in the tested repo arg, and it **MUST** be a local filesystem path. rdc-el-wrapper does
+not support remote tested repos.
+
### Testing build dependencies
You can include source package repositories in the base repository set. This has the effect of
diff --git a/install.requires b/install.requires
index e69de29..f229360 100644
--- a/install.requires
+++ b/install.requires
@@ -0,0 +1 @@
+requests
diff --git a/rdc-el-wrapper.py b/rdc-el-wrapper.py
new file mode 100755
index 0000000..b6aec39
--- /dev/null
+++ b/rdc-el-wrapper.py
@@ -0,0 +1,253 @@
+#!/usr/bin/python3
+
+# Copyright Red Hat
+#
+# This file is part of rmdepcheck.
+#
+# rmdepcheck is free software; you can redistribute it
+# and/or modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+# Author(s): Adam Williamson
+
+
+"""Opinionated rmdepcheck wrapper that handles the awkward Enterprise
+Linux case, where we have to test against multiple shipped variants
+with complex lookaside relationships, all filtered from a shared build
+root.
+"""
+
+# Standard libraries
+
+import argparse
+import ast
+import os
+import pathlib
+import platform
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+from functools import partial
+from urllib.parse import urlparse
+
+from requests import Session
+from requests.adapters import HTTPAdapter
+from requests.packages.urllib3.util.retry import Retry
+
+VrDict = dict[str, str]
+
+DNFTEMP = tempfile.TemporaryDirectory(prefix="rdcelndnf", dir="/var/tmp")
+DNFARGS = ["dnf", "--setopt", f"cachedir={DNFTEMP.name}", "-q", "--disablerepo=*"]
+HERE = os.path.abspath(os.path.dirname(__file__))
+SESSION = Session()
+SUBPCAPTURE = partial(subprocess.run, capture_output=True, text=True, check=False)
+
+
+def split_put(put: pathlib.Path, repotemp: pathlib.Path, vrs: VrDict) -> set[str]:
+ """Splits a directory containing the packages to be tested into
+ multiple repositories per variant. put ('packages under test')
+ is the filesystem path containing packages under test (it cannot
+ be an HTTP URL, in this opinionated wrapper). repotemp is a Path
+ to a temporary directory to store the split repos. vrs is a dict
+ with variant IDs as keys and full compose repo URLs as values.
+ For each package under test, we get its name, look for a package
+ of the same name in each of the variant repositories, and if
+ there is one, we put the package in a subdirectory of repotemp,
+ named for the variant. At the end, we run createrepo in each of
+ the subdirectories we created, and return their names.
+ """
+ if not (put.exists() and put.is_dir()):
+ raise ValueError(f"split_put: invalid put value {put}")
+ put = put.absolute()
+ # generate per-variant package name lists
+ pkgs = {}
+ for variant, repo in vrs.items():
+ args = DNFARGS + [
+ "--repofrompath", f"{variant},{repo}", "repoquery", "--queryformat", "%{name}\n"
+ ]
+ res = SUBPCAPTURE(args)
+ res.check_returncode()
+ pkgs[variant] = res.stdout.splitlines()
+ # track which variants we 'populate'
+ populated = set()
+ for _file in os.listdir(put):
+ if _file.endswith(".rpm"):
+ pkgname = _file.rsplit("-", 2)[0]
+ for (variant, pkgnames) in pkgs.items():
+ if pkgname in pkgnames:
+ populated.add(variant)
+ os.makedirs(repotemp / variant, exist_ok=True)
+ try:
+ # hardlink
+ os.link(put / _file, repotemp / variant / _file)
+ except OSError:
+ # copy
+ shutil.copy2(put / _file, repotemp / variant / _file)
+ for variant in populated:
+ subprocess.run(("createrepo", str(repotemp / variant)), capture_output=True, check=True)
+ return populated
+
+
+def get_variant_repos(compose: str, arch: str) -> VrDict:
+ """Discover variants with repositories in a compose, return a
+ dict with variant IDs as keys and repo URLs as values.
+ """
+ resp = SESSION.get(f"{compose}/metadata/composeinfo.json")
+ resp.raise_for_status()
+ ci = resp.json()
+ variants = ci["payload"]["variants"]
+ ret = {}
+ for variant in variants:
+ if variants[variant]["id"].lower() == "buildroot":
+ continue
+ repopath = variants[variant]["paths"].get("repository", {}).get(arch, "")
+ if not repopath:
+ continue
+ ret[variants[variant]["id"]] = f"{compose}/{repopath}"
+ return ret
+
+
+def get_val():
+ """Get the variants_as_lookaside list which tells us which
+ variants pull in which other variants. FIXME: this is currently
+ ELN specific.
+ """
+ resp = SESSION.get(
+ "https://forge.fedoraproject.org/releng/pungi-fedora/raw/branch/eln/fedora/override.conf"
+ )
+ resp.raise_for_status()
+ override = resp.text
+ # next three lines suggested by AI
+ match = re.search(r"variant_as_lookaside\s*=\s*(\[.*?\])", override, re.DOTALL)
+ if not match:
+ raise ValueError("Could not find variant_as_lookaside in configuration")
+ # eek
+ return ast.literal_eval(match.group(1))
+
+
+def url_check(arg: str) -> str:
+ """Check arg is an http or https URL."""
+ parsed = urlparse(arg)
+ if parsed.scheme in ("http", "https"):
+ return arg
+ if parsed.scheme:
+ raise ValueError(f"Unsupported URL scheme {parsed.scheme} in {arg}")
+ raise ValueError(f"No URL scheme in {arg}")
+
+
+def check_arch(arg: str) -> str:
+ """Use host arch if no value passed."""
+ if arg:
+ return arg
+ return platform.machine()
+
+
+def check_utils() -> None:
+ """Check required utilities are installed."""
+ missing = []
+ for prog in (("dnf", "--version"), ("createrepo", "--version")):
+ try:
+ subprocess.run(prog, stdout=subprocess.DEVNULL, check=True)
+ except FileNotFoundError:
+ missing.append(prog[0])
+ if missing:
+ sys.exit("Please install missing required utilities: " + " ".join(missing))
+
+
+def parse_args() -> argparse.Namespace:
+ """Parse arguments with argparse."""
+ parser = argparse.ArgumentParser(
+ description=("Opinionated rmdepcheck wrapper for EL use cases.")
+ )
+ parser.add_argument(
+ "compose",
+ type=url_check,
+ help="The URL(s) of a compose to discover variant repos from and check against",
+ )
+ parser.add_argument(
+ "repo",
+ type=pathlib.Path,
+ help="Local filesystem path containing packages to test",
+ )
+ parser.add_argument(
+ "--arch",
+ type=check_arch,
+ default="",
+ help="Arch to operate on. Must match arch of passed repository",
+ )
+ return parser.parse_args()
+
+
+def main() -> None:
+ """Main loop."""
+ try:
+ # set up requests session with retries
+ retries = Retry(
+ total=5,
+ backoff_factor=0.5,
+ status_forcelist=[502, 503, 504],
+ allowed_methods={'GET'},
+ )
+ SESSION.mount('https://', HTTPAdapter(max_retries=retries))
+
+ # check DNF is installed
+ check_utils()
+
+ # set up args
+ args = parse_args()
+ if f"--forcearch={args.arch}" not in DNFARGS:
+ DNFARGS.append(f"--forcearch={args.arch}")
+
+ # find variants, do the repo split
+ vrs = get_variant_repos(args.compose, args.arch)
+ val = get_val()
+ with tempfile.TemporaryDirectory(prefix="rdcelnrepo", dir="/var/tmp") as repotd:
+ repotemp = pathlib.Path(repotd)
+ variants = split_put(args.repo, repotemp, vrs)
+
+ rets = set()
+ for variant in variants:
+ # figure out what other variants to pull in per val
+ depvars = set()
+ for pair in val:
+ if pair[0] == variant:
+ depvars.add(pair[1])
+ # put the compose repos for all variants in basearg, even
+ # ones we didn't 'populate' locally
+ basearg = ",".join(vrs[var] for var in set([variant]).union(depvars))
+ newarg = str(repotemp / variant)
+ # put depended-on variant-split PUT repos that actually
+ # exist in addrepos
+ # variants contains only variants we 'populated'
+ addarg = ",".join(str(repotemp / var) for var in depvars if var in variants)
+ # run rmdepcheck, track return code
+ # FIXME: what to do about JSON mode? capture output, combine?
+ rargs = [f"{HERE}/rmdepcheck.py", "--arch", args.arch]
+ if addarg:
+ rargs.extend(("--addrepos", addarg))
+ rargs.extend((basearg, newarg))
+ rets.add(subprocess.run(rargs, check=False).returncode)
+
+ # we exit the sum of all *unique* return codes
+ sys.exit(sum(rets))
+
+ except KeyboardInterrupt:
+ sys.stderr.write("Interrupted, exiting...\n")
+ sys.exit(1)
+
+
+if __name__ == "__main__": # pragma: no cover
+ main()
+
+# vim: set textwidth=100 ts=8 et sw=4: