usm-spm-dnf 16 KB

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