usm-spm-dnf 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  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 conflicts_for(package):
  197. """Names this package conflicts with (rpm Conflicts metadata).
  198. The sack's primary metadata already carries the conflicts tags that
  199. `dnf repoquery --conflicts` prints, so this is an in-process read
  200. with no extra repository query or timeout. Version constraints are
  201. dropped and arch qualifiers ("glib2(x86-64)") reduced to bare names;
  202. anything hawkey cannot name (rich/boolean dependencies) or any
  203. surprise degrades to [].
  204. """
  205. names = set()
  206. try:
  207. for reldep in package.conflicts:
  208. name = getattr(reldep, "name", None)
  209. if not name:
  210. continue
  211. names.add(name.split("(", 1)[0])
  212. except Exception:
  213. return []
  214. return sorted(names)
  215. def cmd_plan(args):
  216. """Resolve the material install set without touching the system.
  217. Marks every name for install, resolves the goal, and prints one
  218. contract JSON object listing every package the manager would
  219. install — the chosen names plus every dependency of a dependency —
  220. so the caller can plan and show the whole native transaction.
  221. """
  222. try:
  223. base = make_base(load_system_repo=True)
  224. for name in args.names:
  225. try:
  226. base.install(name)
  227. except Exception as e:
  228. fail_plain("could not mark \"%s\" for install: %s" % (name, e),
  229. EXIT_RESOLVE)
  230. try:
  231. base.resolve()
  232. except Exception as e:
  233. fail_plain("dependency resolution failed: %s" % e, EXIT_RESOLVE)
  234. names = [member.name for member in base.transaction.install_set]
  235. sys.stdout.write(json.dumps({"packages": names}) + "\n")
  236. sys.stdout.flush()
  237. return EXIT_OK
  238. except SystemExit:
  239. raise
  240. except Exception as e:
  241. fail_plain("plan failed: %s" % e, EXIT_RESOLVE)
  242. def cmd_query(args):
  243. try:
  244. return run_query(args)
  245. except SystemExit:
  246. raise
  247. except Exception as e:
  248. fail_plain("query failed: %s" % e, EXIT_RESOLVE)
  249. def run_query(args):
  250. base = make_base(load_system_repo=True)
  251. installed_keys = set(
  252. (package.name, package.arch)
  253. for package in base.sack.query().installed())
  254. union = base.sack.query().available().union(base.sack.query().installed())
  255. refs = list(dict.fromkeys(args.refs))
  256. not_found = []
  257. candidates = {}
  258. for ref in refs:
  259. descriptors = translate_ref(ref)
  260. if descriptors is None:
  261. sys.stderr.write(
  262. "usm-spm-dnf: resource type of \"%s\" has no "
  263. "system-package-manager translation\n" % ref)
  264. not_found.append(ref)
  265. continue
  266. matches = find_candidates(union, descriptors)
  267. if not matches:
  268. not_found.append(ref)
  269. continue
  270. for name, package in matches.items():
  271. entry = candidates.get(name)
  272. if entry is None:
  273. entry = {"package": package, "resources": []}
  274. candidates[name] = entry
  275. entry["resources"].append(ref)
  276. closure_base = None
  277. closure_cache = {}
  278. packages = []
  279. for name in sorted(candidates):
  280. entry = candidates[name]
  281. if closure_base is None:
  282. closure_base = make_closure_base()
  283. dependency_count, installed_dependency_count = closure_counts(
  284. closure_base, installed_keys, entry["package"], closure_cache)
  285. packages.append({
  286. "name": name,
  287. "resources": entry["resources"],
  288. "dependency-count": dependency_count,
  289. "installed-dependency-count": installed_dependency_count,
  290. "conflicts": conflicts_for(entry["package"]),
  291. })
  292. sys.stdout.write(json.dumps({
  293. "not-found": not_found,
  294. "packages": packages,
  295. }) + "\n")
  296. sys.stdout.flush()
  297. return EXIT_OK
  298. class ContractDownloadProgress(dnf.callback.DownloadProgress):
  299. """Maps dnf package downloads to contract `package` events."""
  300. def __init__(self):
  301. self.total = 0
  302. self.index = 0
  303. self.last = None
  304. def start(self, total_files, total_size, total_drpms=0):
  305. self.total = total_files
  306. self.index = 0
  307. self.last = None
  308. def progress(self, payload, done):
  309. size = payload.pkg.downloadsize or 0
  310. fraction = (done / size) if size else 0.0
  311. event = (
  312. "package", payload.pkg.name, self.index, self.total,
  313. round(min(fraction, 1.0), 4))
  314. if event == self.last:
  315. return
  316. self.last = event
  317. emit_event({
  318. "type": "package",
  319. "name": payload.pkg.name,
  320. "current": self.index,
  321. "total": self.total,
  322. "progress": min(fraction, 1.0),
  323. })
  324. def end(self, payload, status, msg):
  325. if status == dnf.callback.STATUS_FAILED:
  326. raise HelperError(
  327. "failed to download %s: %s" % (payload.pkg.name, msg or "unknown error"),
  328. EXIT_DOWNLOAD)
  329. self.index += 1
  330. def message(self, msg):
  331. sys.stderr.write("usm-spm-dnf: %s\n" % msg)
  332. class ContractTransactionDisplay(dnf.yum.rpmtrans.TransactionDisplay):
  333. """Maps the rpm transaction to contract package/complete events."""
  334. def __init__(self):
  335. super(ContractTransactionDisplay, self).__init__()
  336. self.installed = 0
  337. self.last = None
  338. def progress(self, package, action, ti_done, ti_total, ts_done, ts_total):
  339. if package is None:
  340. return
  341. fraction = (float(ti_done) / float(ti_total)) if ti_total else 0.0
  342. total = ts_total or 1
  343. # ts_done counts finished packages, so the in-flight package is
  344. # the zero-based position ts_done (clamped into the transaction)
  345. current = min(ts_done, total - 1) if ts_total else 0
  346. event = ("package", package.name, current, total,
  347. round(min(fraction, 1.0), 4))
  348. if event == self.last:
  349. return
  350. self.last = event
  351. emit_event({
  352. "type": "package",
  353. "name": package.name,
  354. "current": current,
  355. "total": total,
  356. "progress": min(fraction, 1.0),
  357. })
  358. def filelog(self, package, action):
  359. if package is None or action not in INSTALL_ACTIONS:
  360. return
  361. self.installed += 1
  362. emit_event({"type": "package-complete", "name": package.name})
  363. def scriptout(self, msgs):
  364. if msgs:
  365. sys.stderr.write(msgs if msgs.endswith("\n") else msgs + "\n")
  366. def error(self, message):
  367. raise HelperError(
  368. "transaction failed: %s" % (message or "unknown rpm error"),
  369. EXIT_TRANSACTION)
  370. def cmd_install(args):
  371. base = make_base(load_system_repo=True, test=args.test)
  372. for name in args.names:
  373. try:
  374. base.install(name)
  375. except Exception as e:
  376. fail("could not mark \"%s\" for install: %s" % (name, e),
  377. EXIT_RESOLVE)
  378. try:
  379. base.resolve()
  380. except Exception as e:
  381. fail("dependency resolution failed: %s" % e, EXIT_RESOLVE)
  382. install_set = base.transaction.install_set
  383. emit_event({"type": "begin", "total": len(install_set)})
  384. progress = ContractDownloadProgress()
  385. try:
  386. base.download_packages(install_set, progress=progress)
  387. except HelperError:
  388. raise
  389. except Exception as e:
  390. fail("failed to download packages: %s" % e, EXIT_DOWNLOAD)
  391. display = ContractTransactionDisplay()
  392. try:
  393. base.do_transaction(display=display)
  394. except HelperError:
  395. raise
  396. except Exception as e:
  397. fail("transaction failed: %s" % e, EXIT_TRANSACTION)
  398. emit_event({
  399. "type": "complete",
  400. "status": "ok",
  401. "installed": display.installed,
  402. })
  403. return EXIT_OK
  404. def main():
  405. parser = argparse.ArgumentParser(
  406. prog="usm-spm-dnf",
  407. description="USM system-package-manager helper for DNF")
  408. subparsers = parser.add_subparsers(dest="command", required=True)
  409. query_parser = subparsers.add_parser(
  410. "query", help="resolve USM resource refs to system packages")
  411. query_parser.add_argument(
  412. "refs", nargs="+", metavar="USM-REF",
  413. help="resource ref, e.g. bin:valac or lib:libglib-2.0.so.0")
  414. query_parser.set_defaults(handler=cmd_query)
  415. plan_parser = subparsers.add_parser(
  416. "plan", help="resolve the material install set for names "
  417. "(chosen packages plus all dependencies)")
  418. plan_parser.add_argument(
  419. "names", nargs="+", metavar="NAME",
  420. help="native system package name")
  421. plan_parser.set_defaults(handler=cmd_plan)
  422. install_parser = subparsers.add_parser(
  423. "install", help="install system packages, streaming progress events")
  424. install_parser.add_argument(
  425. "--test", action="store_true",
  426. help="resolve and download only; run the rpm transaction in test mode")
  427. install_parser.add_argument(
  428. "names", nargs="+", metavar="NAME",
  429. help="native system package name")
  430. install_parser.set_defaults(handler=cmd_install)
  431. args = parser.parse_args()
  432. try:
  433. return args.handler(args)
  434. except HelperError as e:
  435. fail(str(e), e.exit_code)
  436. except SystemExit:
  437. raise
  438. except Exception as e:
  439. fail("unexpected failure: %s" % e, EXIT_FAILURE)
  440. if __name__ == "__main__":
  441. sys.exit(main())