usm-spm-dnf 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. #!/usr/bin/env python3
  2. """USM system-package-manager helper for DNF-based systems.
  3. Implements the USM SPM contract (see usm/README.md, "System package manager
  4. integration") for Fedora-style systems:
  5. usm-spm-dnf query <usm-ref>... -> contract JSON on STDOUT
  6. usm-spm-dnf install [--test] <name>... -> contract JSONL events on STDOUT
  7. API choice: Fedora 43 ships dnf5 as the CLI, but this machine exposes the
  8. DNF4 Python bindings (`python3-dnf`, `import dnf` succeeds) while the dnf5
  9. bindings (`python3-libdnf5`, `import libdnf5`) are not installed. This helper
  10. is therefore implemented against the DNF4 Python API, which resolves against
  11. the same repositories and rpmdb as dnf5. If python3-libdnf5 becomes the only
  12. option, this file is the one to port.
  13. The query subcommand never modifies system state (at most it refreshes
  14. package-metadata caches). The install subcommand must run as root; --test
  15. resolves and downloads but runs the rpm transaction in test mode only.
  16. """
  17. import argparse
  18. import json
  19. import os
  20. import sys
  21. import dnf
  22. import dnf.callback
  23. import dnf.transaction
  24. import dnf.yum.rpmtrans
  25. import hawkey
  26. BASE_ARCH = hawkey.detect_arch()
  27. INSTALL_ACTIONS = frozenset([
  28. dnf.transaction.PKG_INSTALL,
  29. dnf.transaction.PKG_UPGRADE,
  30. dnf.transaction.PKG_DOWNGRADE,
  31. dnf.transaction.PKG_REINSTALL,
  32. ])
  33. EXIT_OK = 0
  34. EXIT_FAILURE = 1
  35. EXIT_RESOLVE = 3
  36. EXIT_DOWNLOAD = 4
  37. EXIT_TRANSACTION = 5
  38. class HelperError(Exception):
  39. """Fatal helper failure; message is emitted as a contract error event."""
  40. def __init__(self, message, exit_code=EXIT_FAILURE):
  41. super(HelperError, self).__init__(message)
  42. self.exit_code = exit_code
  43. def emit_event(event):
  44. """Write one contract JSONL event to STDOUT and flush it."""
  45. sys.stdout.write(json.dumps(event) + "\n")
  46. sys.stdout.flush()
  47. def fail(message, exit_code=EXIT_FAILURE):
  48. """Emit the terminal error event and exit non-zero."""
  49. emit_event({"type": "error", "message": message})
  50. sys.exit(exit_code)
  51. def fail_plain(message, exit_code=EXIT_FAILURE):
  52. """Report a query failure on STDERR only, keeping STDOUT contract-clean."""
  53. sys.stderr.write("usm-spm-dnf: %s\n" % message)
  54. sys.exit(exit_code)
  55. def make_base(load_system_repo, test=False):
  56. """Build a filled dnf.Base, using a user cachedir when unprivileged.
  57. Filelists metadata is enabled in code before the sack load: the DNF4
  58. Python API downloads only primary metadata by default (bin:/lib:
  59. provides queries) and silently ignores optional_metadata_types set via
  60. dnf.conf, so pc:/vapi:/file-path queries would return not-found on any
  61. machine whose cache lacks filelists (fresh containers, for instance).
  62. """
  63. base = dnf.Base()
  64. if "filelists" not in base.conf.optional_metadata_types:
  65. base.conf.optional_metadata_types.append("filelists")
  66. if os.geteuid() != 0:
  67. base.conf.cachedir = os.path.expanduser("~/.cache/dnf")
  68. base.conf.history_record = False
  69. if test:
  70. base.conf.tsflags.append("test")
  71. base.read_all_repos()
  72. base.fill_sack(
  73. load_system_repo=load_system_repo,
  74. load_available_repos=True,
  75. )
  76. return base
  77. def make_closure_base():
  78. """A repo-only sack restricted to the running arch for goal closures.
  79. dependency-count models "a solo install on this machine", so packages
  80. for foreign architectures are excluded the way a real single-arch
  81. install would never pull them.
  82. """
  83. base = make_base(load_system_repo=False)
  84. foreign = base.sack.query().filter(
  85. arch=[a for a in ("i686", "i386", "armv7hl", "ppc64le", "s390x")
  86. if a != BASE_ARCH])
  87. base.sack.add_excludes(foreign)
  88. return base
  89. def translate_ref(ref):
  90. """Map a USM resource ref to (filter_kind, value) sack query descriptors.
  91. Returns None for refs whose type has no file/provides translation.
  92. """
  93. prefix, sep, resource = ref.partition(":")
  94. if not sep or not resource:
  95. return None
  96. file_roots = {
  97. "bin": "/usr/bin",
  98. "sbin": "/usr/sbin",
  99. "libexec": "/usr/libexec",
  100. "gir": "/usr/share/gir-1.0",
  101. "typelib": "/usr/lib64/girepository-1.0",
  102. "res": "/usr/share",
  103. "cfg": "/etc",
  104. "man": "/usr/share/man",
  105. "info": "/usr/share/info",
  106. "locale": "/usr/share/locale",
  107. }
  108. if prefix in file_roots:
  109. return [("file", file_roots[prefix] + "/" + resource)]
  110. if prefix == "vapi":
  111. return [
  112. ("file", "/usr/share/vala/vapi/" + resource),
  113. ("file__glob", "/usr/share/vala-*/vapi/" + resource),
  114. ]
  115. if prefix == "lib":
  116. return [
  117. ("provides", resource),
  118. ("file", "/usr/lib64/" + resource),
  119. ]
  120. if prefix == "pc":
  121. return [
  122. ("file", "/usr/share/pkgconfig/" + resource),
  123. ("file", "/usr/lib64/pkgconfig/" + resource),
  124. ]
  125. if prefix == "inc":
  126. return [("file__glob", "/usr/include/" + resource + "*")]
  127. if prefix == "rootpath":
  128. return [("file", "/" + resource.lstrip("/"))]
  129. if prefix == "tag":
  130. return [("file", "/usr/share/usm-tags/" + resource.replace(".", "/") + ".tag")]
  131. return None
  132. def arch_rank(package):
  133. """Sort key preferring the running arch, then noarch, then others."""
  134. if package.arch == BASE_ARCH:
  135. return (0, package.arch)
  136. if package.arch == "noarch":
  137. return (1, package.arch)
  138. return (2, package.arch)
  139. def find_candidates(union, descriptors):
  140. """Best package per name matching any descriptor, latest version per arch."""
  141. by_name = {}
  142. for kind, value in descriptors:
  143. matches = union.filter(**{kind: value}).filter(latest_per_arch=True)
  144. for package in matches:
  145. current = by_name.get(package.name)
  146. if current is None or arch_rank(package) < arch_rank(current):
  147. by_name[package.name] = package
  148. return by_name
  149. def closure_counts(closure_base, installed_keys, package, closure_cache):
  150. """(dependency-count, installed-dependency-count) for a solo install.
  151. The full closure (including the package itself) comes from a goal run
  152. against a sack with no @System, so every dependency resolves as an
  153. install; the overlap with @System is then counted by (name, arch).
  154. """
  155. cache_key = (package.name, package.arch)
  156. if cache_key in closure_cache:
  157. return closure_cache[cache_key]
  158. target_query = closure_base.sack.query().filter(
  159. name=package.name,
  160. arch=package.arch,
  161. epoch=package.epoch,
  162. version=package.version,
  163. release=package.release,
  164. )
  165. target = next(iter(target_query), None)
  166. if target is None:
  167. target = next(iter(
  168. closure_base.sack.query().filter(
  169. name=package.name, arch=package.arch)
  170. .filter(latest_per_arch=True)), None)
  171. counts = (1, 1 if cache_key in installed_keys else 0)
  172. if target is not None:
  173. goal = dnf.goal.Goal(closure_base.sack)
  174. goal.install(target)
  175. if goal.run():
  176. transaction = (goal.list_installs() + goal.list_upgrades()
  177. + goal.list_downgrades() + goal.list_reinstalls())
  178. counts = (
  179. len(transaction),
  180. sum(1 for member in transaction
  181. if (member.name, member.arch) in installed_keys),
  182. )
  183. else:
  184. sys.stderr.write(
  185. "usm-spm-dnf: could not resolve solo install of %s: %s\n"
  186. % (package, goal.problem_string() if hasattr(goal, "problem_string") else "unresolved dependency"))
  187. else:
  188. sys.stderr.write(
  189. "usm-spm-dnf: %s not found in enabled repositories, "
  190. "estimating dependency counts as installed-only\n" % package)
  191. closure_cache[cache_key] = counts
  192. return counts
  193. def cmd_query(args):
  194. try:
  195. return run_query(args)
  196. except SystemExit:
  197. raise
  198. except Exception as e:
  199. fail_plain("query failed: %s" % e, EXIT_RESOLVE)
  200. def run_query(args):
  201. base = make_base(load_system_repo=True)
  202. installed_keys = set(
  203. (package.name, package.arch)
  204. for package in base.sack.query().installed())
  205. union = base.sack.query().available().union(base.sack.query().installed())
  206. refs = list(dict.fromkeys(args.refs))
  207. not_found = []
  208. candidates = {}
  209. for ref in refs:
  210. descriptors = translate_ref(ref)
  211. if descriptors is None:
  212. sys.stderr.write(
  213. "usm-spm-dnf: resource type of \"%s\" has no "
  214. "system-package-manager translation\n" % ref)
  215. not_found.append(ref)
  216. continue
  217. matches = find_candidates(union, descriptors)
  218. if not matches:
  219. not_found.append(ref)
  220. continue
  221. for name, package in matches.items():
  222. entry = candidates.get(name)
  223. if entry is None:
  224. entry = {"package": package, "resources": []}
  225. candidates[name] = entry
  226. entry["resources"].append(ref)
  227. closure_base = None
  228. closure_cache = {}
  229. packages = []
  230. for name in sorted(candidates):
  231. entry = candidates[name]
  232. if closure_base is None:
  233. closure_base = make_closure_base()
  234. dependency_count, installed_dependency_count = closure_counts(
  235. closure_base, installed_keys, entry["package"], closure_cache)
  236. packages.append({
  237. "name": name,
  238. "resources": entry["resources"],
  239. "dependency-count": dependency_count,
  240. "installed-dependency-count": installed_dependency_count,
  241. })
  242. sys.stdout.write(json.dumps({
  243. "not-found": not_found,
  244. "packages": packages,
  245. }) + "\n")
  246. sys.stdout.flush()
  247. return EXIT_OK
  248. class ContractDownloadProgress(dnf.callback.DownloadProgress):
  249. """Maps dnf package downloads to contract `package` events."""
  250. def __init__(self):
  251. self.total = 0
  252. self.index = 0
  253. self.last = None
  254. def start(self, total_files, total_size, total_drpms=0):
  255. self.total = total_files
  256. self.index = 0
  257. self.last = None
  258. def progress(self, payload, done):
  259. size = payload.pkg.downloadsize or 0
  260. fraction = (done / size) if size else 0.0
  261. event = (
  262. "package", payload.pkg.name, self.index + 1, self.total,
  263. round(min(fraction, 1.0), 4))
  264. if event == self.last:
  265. return
  266. self.last = event
  267. emit_event({
  268. "type": "package",
  269. "name": payload.pkg.name,
  270. "current": self.index + 1,
  271. "total": self.total,
  272. "progress": min(fraction, 1.0),
  273. })
  274. def end(self, payload, status, msg):
  275. if status == dnf.callback.STATUS_FAILED:
  276. raise HelperError(
  277. "failed to download %s: %s" % (payload.pkg.name, msg or "unknown error"),
  278. EXIT_DOWNLOAD)
  279. self.index += 1
  280. def message(self, msg):
  281. sys.stderr.write("usm-spm-dnf: %s\n" % msg)
  282. class ContractTransactionDisplay(dnf.yum.rpmtrans.TransactionDisplay):
  283. """Maps the rpm transaction to contract package/complete events."""
  284. def __init__(self):
  285. super(ContractTransactionDisplay, self).__init__()
  286. self.installed = 0
  287. self.last = None
  288. def progress(self, package, action, ti_done, ti_total, ts_done, ts_total):
  289. if package is None:
  290. return
  291. fraction = (float(ti_done) / float(ti_total)) if ti_total else 0.0
  292. total = ts_total or 1
  293. current = min(ts_done + 1, total) if ts_total else 1
  294. event = ("package", package.name, current, total,
  295. round(min(fraction, 1.0), 4))
  296. if event == self.last:
  297. return
  298. self.last = event
  299. emit_event({
  300. "type": "package",
  301. "name": package.name,
  302. "current": current,
  303. "total": total,
  304. "progress": min(fraction, 1.0),
  305. })
  306. def filelog(self, package, action):
  307. if package is None or action not in INSTALL_ACTIONS:
  308. return
  309. self.installed += 1
  310. emit_event({"type": "package-complete", "name": package.name})
  311. def scriptout(self, msgs):
  312. if msgs:
  313. sys.stderr.write(msgs if msgs.endswith("\n") else msgs + "\n")
  314. def error(self, message):
  315. raise HelperError(
  316. "transaction failed: %s" % (message or "unknown rpm error"),
  317. EXIT_TRANSACTION)
  318. def cmd_install(args):
  319. base = make_base(load_system_repo=True, test=args.test)
  320. for name in args.names:
  321. try:
  322. base.install(name)
  323. except Exception as e:
  324. fail("could not mark \"%s\" for install: %s" % (name, e),
  325. EXIT_RESOLVE)
  326. try:
  327. base.resolve()
  328. except Exception as e:
  329. fail("dependency resolution failed: %s" % e, EXIT_RESOLVE)
  330. install_set = base.transaction.install_set
  331. emit_event({"type": "begin", "total": len(install_set)})
  332. progress = ContractDownloadProgress()
  333. try:
  334. base.download_packages(install_set, progress=progress)
  335. except HelperError:
  336. raise
  337. except Exception as e:
  338. fail("failed to download packages: %s" % e, EXIT_DOWNLOAD)
  339. display = ContractTransactionDisplay()
  340. try:
  341. base.do_transaction(display=display)
  342. except HelperError:
  343. raise
  344. except Exception as e:
  345. fail("transaction failed: %s" % e, EXIT_TRANSACTION)
  346. emit_event({
  347. "type": "complete",
  348. "status": "ok",
  349. "installed": display.installed,
  350. })
  351. return EXIT_OK
  352. def main():
  353. parser = argparse.ArgumentParser(
  354. prog="usm-spm-dnf",
  355. description="USM system-package-manager helper for DNF")
  356. subparsers = parser.add_subparsers(dest="command", required=True)
  357. query_parser = subparsers.add_parser(
  358. "query", help="resolve USM resource refs to system packages")
  359. query_parser.add_argument(
  360. "refs", nargs="+", metavar="USM-REF",
  361. help="resource ref, e.g. bin:valac or lib:libglib-2.0.so.0")
  362. query_parser.set_defaults(handler=cmd_query)
  363. install_parser = subparsers.add_parser(
  364. "install", help="install system packages, streaming progress events")
  365. install_parser.add_argument(
  366. "--test", action="store_true",
  367. help="resolve and download only; run the rpm transaction in test mode")
  368. install_parser.add_argument(
  369. "names", nargs="+", metavar="NAME",
  370. help="native system package name")
  371. install_parser.set_defaults(handler=cmd_install)
  372. args = parser.parse_args()
  373. try:
  374. return args.handler(args)
  375. except HelperError as e:
  376. fail(str(e), e.exit_code)
  377. except SystemExit:
  378. raise
  379. except Exception as e:
  380. fail("unexpected failure: %s" % e, EXIT_FAILURE)
  381. if __name__ == "__main__":
  382. sys.exit(main())