usm-spm-emerge 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
  1. #!/usr/bin/env python3
  2. """USM system-package-manager helper for Portage (Gentoo) systems.
  3. Implements the USM SPM contract (see usm/README.md, "System package manager
  4. integration") for Gentoo systems:
  5. usm-spm-emerge query <usm-ref>... -> contract JSON on STDOUT
  6. usm-spm-emerge install [--test] <atom>... -> contract JSONL events on STDOUT
  7. API choice: a Gentoo stage3 ships Portage and python3, so this helper reads
  8. the installed-package database through the portage Python API (with a raw
  9. /var/db/pkg CONTENTS scan as fallback) and shells out to `emerge` for
  10. dependency resolution (pretend) and installation. There is no binary
  11. metadata to bind against beyond that: ebuild repositories carry no file
  12. lists, so unlike the dnf/apt/apk helpers this one cannot search "which
  13. package contains file X" for packages that are not installed yet.
  14. File-backed refs are therefore resolved in two documented stages:
  15. 1. INSTALLED files (exact): the ref is translated to candidate filesystem
  16. paths (gentoo layout: /usr/bin, /usr/sbin, /usr/lib64, /usr/share, ...)
  17. and matched against the portage vdb (/var/db/pkg/*/CONTENTS). A ref
  18. resolves to the installed package that owns the file.
  19. 2. NOT-installed files (exception table only): Gentoo has no offline
  20. file-provides (PFL, the portage file list, is an online service), so a
  21. small curated table maps the USM stack's build/platform refs to Gentoo
  22. package atoms (valac -> dev-lang/vala, glib-2.0.pc -> dev-libs/glib,
  23. ...). Refs outside the table land in not-found; anything else would be
  24. guesswork.
  25. dependency-count / installed-dependency-count are honest numbers parsed
  26. from `emerge --pretend --quiet=y --ask=n <atom>`: dependency-count is the
  27. number of packages in the merge list including the package itself,
  28. installed-dependency-count is the subset of the merge list already
  29. installed (action letters without N, e.g. [ebuild R]/[ebuild U] reinstalls
  30. and upgrades; a fresh stage3 typically shows an all-N list). If the pretend
  31. run fails (atom missing from the tree, masked, unresolvable) an
  32. exception-table candidate falls back to not-found.
  33. Install maps `emerge --ask=n --quiet=y <atoms>` onto the JSONL contract,
  34. linking the unversioned tool names Gentoo ships versioned (UNVERSIONED_TOOL_LINKS):
  35. `begin`/`total` come from a pretend run first, a `package` event is emitted
  36. per ">>> Emerging (n of N)" status line, `package-complete` per
  37. ">>> Installing (n of N)" line (the moment a merge lands in the vdb),
  38. `complete` carries the number of completed merges, and any failure emits a
  39. terminal `error` event before a non-zero exit (resolution failures map to
  40. exit 3, download failures to 4, build/merge failures to 5).
  41. Documented Gentoo limitations:
  42. * No offline file-provides for not-yet-installed packages (see stage 2
  43. above); installed-file queries are exact.
  44. * USE flags are never touched: emerge runs with the system's configured
  45. defaults. The stack's platform libraries (glib, json-glib, libgee, ...)
  46. enable their introspection USE flag by default, but a system that
  47. disabled it (package.use/make.conf) will lack the matching .gir/
  48. .typelib/.vapi artifacts after install; re-enable the flag and re-emerge
  49. if a build needs them.
  50. * Packages are compiled from source; even small installs take minutes.
  51. Progress granularity is per package; emerge exposes no useful
  52. intra-package progress, so `package` events carry progress 0.0 and the
  53. completion signal is the ">>> Installing" line.
  54. * The query subcommand never modifies system state (at most Portage
  55. regenerates its dependency cache); install must run as root and never
  56. prompts (--ask=n answers Portage's own prompt for it).
  57. """
  58. import argparse
  59. import glob
  60. import json
  61. import os
  62. import re
  63. import subprocess
  64. import sys
  65. import threading
  66. EMERGE = "/usr/bin/emerge"
  67. EXIT_OK = 0
  68. EXIT_FAILURE = 1
  69. EXIT_RESOLVE = 3
  70. EXIT_DOWNLOAD = 4
  71. EXIT_TRANSACTION = 5
  72. FILE_ROOTS = {
  73. "sbin": ["/usr/sbin", "/sbin"],
  74. "libexec": ["/usr/libexec"],
  75. "gir": ["/usr/share/gir-1.0"],
  76. "typelib": [
  77. "/usr/lib64/girepository-1.0",
  78. "/usr/lib/girepository-1.0",
  79. "/lib64/girepository-1.0",
  80. "/lib/girepository-1.0",
  81. ],
  82. "res": ["/usr/share"],
  83. "cfg": ["/etc"],
  84. "man": ["/usr/share/man"],
  85. "info": ["/usr/share/info"],
  86. "locale": ["/usr/share/locale"],
  87. "app": ["/usr/share/applications"],
  88. "opt": ["/opt"],
  89. "rootpath": [""],
  90. "tag": ["/usr/share/usm-tags"],
  91. }
  92. LIB_ROOTS = ["/usr/lib64", "/lib64", "/usr/lib", "/lib"]
  93. PC_ROOTS = ["/usr/lib64/pkgconfig", "/usr/share/pkgconfig", "/usr/lib/pkgconfig"]
  94. TRANSLATED_PREFIXES = set(FILE_ROOTS) | {
  95. "bin", "lib", "libres", "pc", "vapi", "inc"}
  96. USR_MERGE_ALIASES = [
  97. ("/lib64/", "/usr/lib64/"),
  98. ("/lib/", "/usr/lib/"),
  99. ("/bin/", "/usr/bin/"),
  100. ("/sbin/", "/usr/sbin/"),
  101. ]
  102. EXCEPTIONS = {
  103. "bin:valac": "dev-lang/vala",
  104. "bin:vapigen": "dev-lang/vala",
  105. "bin:meson": "dev-build/meson",
  106. "bin:ninja": "dev-build/ninja",
  107. "bin:pkg-config": "dev-util/pkgconf",
  108. "bin:pkgconf": "dev-util/pkgconf",
  109. "bin:g-ir-scanner": "dev-libs/gobject-introspection",
  110. "bin:g-ir-compiler": "dev-libs/gobject-introspection",
  111. "bin:gcc": "sys-devel/gcc",
  112. "bin:g++": "sys-devel/gcc",
  113. "bin:cc": "sys-devel/gcc",
  114. "bin:c++": "sys-devel/gcc",
  115. "bin:ld": "sys-devel/binutils",
  116. "bin:make": "dev-build/make",
  117. "bin:python3": "dev-lang/python",
  118. "bin:bash": "app-shells/bash",
  119. "bin:git": "dev-vcs/git",
  120. "bin:curl": "net-misc/curl",
  121. "bin:tar": "app-arch/tar",
  122. "bin:xz": "app-arch/xz-utils",
  123. "bin:gzip": "app-arch/gzip",
  124. "bin:sed": "sys-apps/sed",
  125. "bin:awk": "sys-apps/gawk",
  126. "bin:grep": "sys-apps/grep",
  127. "bin:ldconfig": "sys-libs/glibc",
  128. "pc:glib-2.0.pc": "dev-libs/glib",
  129. "pc:gobject-2.0.pc": "dev-libs/glib",
  130. "pc:gio-2.0.pc": "dev-libs/glib",
  131. "pc:gio-unix-2.0.pc": "dev-libs/glib",
  132. "pc:gmodule-2.0.pc": "dev-libs/glib",
  133. "pc:gmodule-no-export-2.0.pc": "dev-libs/glib",
  134. "pc:gthread-2.0.pc": "dev-libs/glib",
  135. "pc:json-glib-1.0.pc": "dev-libs/json-glib",
  136. "pc:gee-0.8.pc": "dev-libs/libgee",
  137. "pc:libsodium.pc": "dev-libs/libsodium",
  138. "pc:libarchive.pc": "app-arch/libarchive",
  139. "pc:sqlite3.pc": "dev-db/sqlite",
  140. "pc:libxml-2.0.pc": "dev-libs/libxml2",
  141. "pc:libsoup-3.0.pc": "net-libs/libsoup",
  142. "pc:libmicrohttpd.pc": "net-libs/libmicrohttpd",
  143. "pc:gobject-introspection-1.0.pc": "dev-libs/gobject-introspection",
  144. "pc:zlib.pc": "sys-libs/zlib",
  145. "pc:libzstd.pc": "app-arch/zstd",
  146. "pc:libbrotlienc.pc": "app-arch/brotli",
  147. "pc:libbrotlidec.pc": "app-arch/brotli",
  148. "pc:libbrotlicommon.pc": "app-arch/brotli",
  149. "pc:libffi.pc": "dev-libs/libffi",
  150. "pc:pcre2.pc": "dev-libs/libpcre2",
  151. "vapi:glib-2.0.vapi": "dev-lang/vala",
  152. "vapi:gobject-2.0.vapi": "dev-lang/vala",
  153. "vapi:gio-2.0.vapi": "dev-lang/vala",
  154. "vapi:gio-unix-2.0.vapi": "dev-lang/vala",
  155. "vapi:posix.vapi": "dev-lang/vala",
  156. "vapi:json-glib-1.0.vapi": "dev-lang/vala",
  157. "vapi:libxml-2.0.vapi": "dev-lang/vala",
  158. "vapi:sqlite3.vapi": "dev-lang/vala",
  159. "vapi:gee-0.8.vapi": "dev-libs/libgee",
  160. "gir:GLib-2.0.gir": "dev-libs/gobject-introspection",
  161. "gir:GObject-2.0.gir": "dev-libs/gobject-introspection",
  162. "gir:Gio-2.0.gir": "dev-libs/gobject-introspection",
  163. "gir:GioUnix-2.0.gir": "dev-libs/gobject-introspection",
  164. "typelib:GLib-2.0.typelib": "dev-libs/gobject-introspection",
  165. "typelib:GObject-2.0.typelib": "dev-libs/gobject-introspection",
  166. "typelib:Gio-2.0.typelib": "dev-libs/gobject-introspection",
  167. "typelib:GioUnix-2.0.typelib": "dev-libs/gobject-introspection",
  168. "lib:libglib-2.0.so.0": "dev-libs/glib",
  169. "lib:libgobject-2.0.so.0": "dev-libs/glib",
  170. "lib:libgio-2.0.so.0": "dev-libs/glib",
  171. "lib:libgmodule-2.0.so.0": "dev-libs/glib",
  172. "lib:libgthread-2.0.so.0": "dev-libs/glib",
  173. "lib:libjson-glib-1.0.so.0": "dev-libs/json-glib",
  174. "lib:libgee-0.8.so.2": "dev-libs/libgee",
  175. "lib:libsodium.so.26": "dev-libs/libsodium",
  176. "lib:libarchive.so.13": "app-arch/libarchive",
  177. "lib:libsqlite3.so.0": "dev-db/sqlite",
  178. "lib:libxml2.so.2": "dev-libs/libxml2",
  179. "lib:libsoup-3.0.so.0": "net-libs/libsoup",
  180. "lib:libmicrohttpd.so.12": "net-libs/libmicrohttpd",
  181. "lib:libz.so.1": "sys-libs/zlib",
  182. "lib:libzstd.so.1": "app-arch/zstd",
  183. "lib:libbrotlienc.so.1": "app-arch/brotli",
  184. "lib:libbrotlidec.so.1": "app-arch/brotli",
  185. "lib:libbrotlicommon.so.1": "app-arch/brotli",
  186. "lib:libffi.so.8": "dev-libs/libffi",
  187. "lib:libpcre2-8.so.0": "dev-libs/libpcre2",
  188. "lib:libc.so.6": "sys-libs/glibc",
  189. "lib:libm.so.6": "sys-libs/glibc",
  190. "lib:libblkid.so.1": "sys-apps/util-linux",
  191. "lib:libmount.so.1": "sys-apps/util-linux",
  192. "lib:libuuid.so.1": "sys-apps/util-linux",
  193. "inc:glib-2.0": "dev-libs/glib",
  194. "inc:json-glib-1.0": "dev-libs/json-glib",
  195. "inc:gee-0.8": "dev-libs/libgee",
  196. "inc:libsodium.h": "dev-libs/libsodium",
  197. "inc:archive.h": "app-arch/libarchive",
  198. "inc:sqlite3.h": "dev-db/sqlite",
  199. }
  200. class HelperError(Exception):
  201. """Fatal helper failure; message is emitted as a contract error event."""
  202. def __init__(self, message, exit_code=EXIT_FAILURE):
  203. super(HelperError, self).__init__(message)
  204. self.exit_code = exit_code
  205. def emit_event(event):
  206. """Write one contract JSONL event to STDOUT and flush it."""
  207. sys.stdout.write(json.dumps(event) + "\n")
  208. sys.stdout.flush()
  209. def fail(message, exit_code=EXIT_FAILURE):
  210. """Emit the terminal error event and exit non-zero."""
  211. emit_event({"type": "error", "message": message})
  212. sys.exit(exit_code)
  213. def fail_plain(message, exit_code=EXIT_FAILURE):
  214. """Report a query failure on STDERR only, keeping STDOUT contract-clean."""
  215. sys.stderr.write("usm-spm-emerge: %s\n" % message)
  216. sys.exit(exit_code)
  217. def category_package(cpv):
  218. """Strip version and ::repository suffix from a cpv -> category/package."""
  219. atom = cpv.split("::", 1)[0]
  220. try:
  221. import portage
  222. key = portage.cpv_getkey(atom)
  223. if key:
  224. return key
  225. except Exception:
  226. pass
  227. category, sep, package_version = atom.partition("/")
  228. if not sep:
  229. return atom
  230. return re.sub(r"-\d.*$", "", package_version) or package_version
  231. def parse_contents_line(line):
  232. """Path recorded on one CONTENTS line, or None for junk lines."""
  233. fields = line.split()
  234. if len(fields) >= 2 and fields[0] in ("obj", "dir", "sym", "dev", "fif"):
  235. return fields[1]
  236. return None
  237. def build_vdb_index():
  238. """Map every installed file path -> cpv via the portage vdb.
  239. Prefers the portage API (aux_get CONTENTS, present on any portage
  240. system); falls back to a raw /var/db/pkg/<cat>/<pkg>/CONTENTS scan.
  241. """
  242. index = {}
  243. try:
  244. import portage
  245. root = portage.settings.get("ROOT", "/")
  246. vdb = portage.db[root]["vartree"].dbapi
  247. for cpv in vdb.cpv_all():
  248. raw = vdb.aux_get(cpv, ["CONTENTS"])[0] or ""
  249. for line in raw.splitlines():
  250. path = parse_contents_line(line)
  251. if path is not None:
  252. index.setdefault(path, cpv)
  253. if index:
  254. return index
  255. except Exception as e:
  256. sys.stderr.write("usm-spm-emerge: portage API vdb read failed (%s), "
  257. "falling back to /var/db/pkg scan\n" % e)
  258. for contents_path in glob.glob("/var/db/pkg/*/*/CONTENTS"):
  259. category = os.path.basename(os.path.dirname(os.path.dirname(contents_path)))
  260. package_version = os.path.basename(os.path.dirname(contents_path))
  261. cpv = category + "/" + package_version
  262. try:
  263. with open(contents_path, "r", errors="replace") as handle:
  264. for line in handle:
  265. path = parse_contents_line(line)
  266. if path is not None:
  267. index.setdefault(path, cpv)
  268. except OSError:
  269. continue
  270. return index
  271. def path_aliases(path):
  272. """path plus its /usr-merge variant(s) (/lib64/x -> /usr/lib64/x...)."""
  273. aliases = [path]
  274. for merged, unmerged in USR_MERGE_ALIASES:
  275. if path.startswith(merged):
  276. variant = unmerged + path[len(merged):]
  277. elif path.startswith(unmerged):
  278. variant = merged + path[len(unmerged):]
  279. else:
  280. continue
  281. if variant not in aliases:
  282. aliases.append(variant)
  283. return aliases
  284. def bin_candidates(resource):
  285. """PATH directories in order (plus /usr/bin) as candidate directories."""
  286. directories = [d for d in os.environ.get("PATH", "").split(os.pathsep) if d]
  287. if "/usr/bin" not in directories:
  288. directories.append("/usr/bin")
  289. return [os.path.join(d, resource) for d in directories]
  290. def translate_paths(prefix, resource):
  291. """Candidate filesystem paths for a file-backed ref prefix."""
  292. if prefix == "bin":
  293. return bin_candidates(resource)
  294. if prefix == "tag":
  295. return ["/usr/share/usm-tags/" + resource.replace(".", "/") + ".tag"]
  296. if prefix in FILE_ROOTS:
  297. return ["/".join(FILE_ROOTS[prefix] + [resource])]
  298. if prefix in ("lib", "libres"):
  299. return [os.path.join(d, resource) for d in LIB_ROOTS]
  300. if prefix == "pc":
  301. return [os.path.join(d, resource) for d in PC_ROOTS]
  302. return None
  303. def resolve_installed(ref, index):
  304. """cpv of the installed package owning the ref's file, or None.
  305. Candidate paths (PATH order for bin:, gentoo root table otherwise) are
  306. matched exactly against the vdb index, with /usr-merge aliases tried on
  307. both sides; files present on disk but owned by no package do not count
  308. as provided by the system package manager.
  309. """
  310. prefix, sep, resource = ref.partition(":")
  311. if not sep or not resource:
  312. return None
  313. resource = resource.strip("/")
  314. if prefix == "vapi":
  315. pattern = re.compile(
  316. r"^/usr/share/vala[^/]*/vapi/" + re.escape(resource) + "$")
  317. for path, cpv in index.items():
  318. if pattern.match(path):
  319. return cpv
  320. return None
  321. if prefix == "inc":
  322. directory = "/usr/include/" + resource.strip("/")
  323. for path, cpv in index.items():
  324. if path == directory or path.startswith(directory + "/"):
  325. return cpv
  326. return None
  327. candidates = translate_paths(prefix, resource)
  328. if candidates is None:
  329. return None
  330. for path in candidates:
  331. for alias in path_aliases(path):
  332. if alias in index:
  333. return index[alias]
  334. return None
  335. def emerge_pretend(atoms):
  336. """Run emerge --pretend; returns (returncode, stdout, stderr)."""
  337. command = [EMERGE, "--pretend", "--quiet=y", "--ask=n"] + list(atoms)
  338. try:
  339. proc = subprocess.run(
  340. command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
  341. text=True, errors="replace")
  342. except OSError as e:
  343. return EXIT_FAILURE, "", str(e)
  344. return proc.returncode, proc.stdout, proc.stderr
  345. MERGE_LINE = re.compile(r"^\[(ebuild|binary)\s+([A-Za-z*]+)\s*\]\s+(\S+)")
  346. def parse_merge_list(output):
  347. """(total, already_installed, atoms) from pretend output merge lines."""
  348. total = 0
  349. installed = 0
  350. atoms = []
  351. for line in output.splitlines():
  352. match = MERGE_LINE.match(line)
  353. if not match:
  354. continue
  355. total += 1
  356. if "N" not in match.group(2):
  357. installed += 1
  358. atoms.append(match.group(3))
  359. return total, installed, atoms
  360. def pretend_counts(atom, cache):
  361. """(dependency-count, installed-dependency-count) for a solo emerge.
  362. None means the atom cannot be resolved against the tree at all (no
  363. ebuilds, masked, conflicting): exception-table candidates then fall
  364. back to not-found; installed candidates degrade to (1, 1).
  365. """
  366. if atom in cache:
  367. return cache[atom]
  368. returncode, stdout, stderr = emerge_pretend([atom])
  369. if returncode != 0:
  370. cache[atom] = None
  371. return None
  372. total, installed, _ = parse_merge_list(stdout)
  373. counts = (total, installed) if total else (1, 1)
  374. cache[atom] = counts
  375. return counts
  376. def cmd_query(args):
  377. try:
  378. return run_query(args)
  379. except SystemExit:
  380. raise
  381. except Exception as e:
  382. fail_plain("query failed: %s" % e, EXIT_RESOLVE)
  383. def run_query(args):
  384. index = build_vdb_index()
  385. refs = list(dict.fromkeys(args.refs))
  386. candidates = {}
  387. resolved_ref = {}
  388. for ref in refs:
  389. cpv = resolve_installed(ref, index)
  390. if cpv is not None:
  391. name = category_package(cpv)
  392. else:
  393. name = EXCEPTIONS.get(ref)
  394. if name is None:
  395. if ref.partition(":")[0] not in TRANSLATED_PREFIXES:
  396. sys.stderr.write(
  397. "usm-spm-emerge: resource type of \"%s\" has no "
  398. "system-package-manager translation\n" % ref)
  399. continue
  400. resolved_ref[ref] = name
  401. entry = candidates.setdefault(name, {"resources": [], "installed": False})
  402. entry["resources"].append(ref)
  403. if cpv is not None:
  404. entry["installed"] = True
  405. counts_cache = {}
  406. package_counts = {}
  407. for name, entry in candidates.items():
  408. counts = pretend_counts(name, counts_cache)
  409. if counts is None and entry["installed"]:
  410. sys.stderr.write(
  411. "usm-spm-emerge: %s is installed but emerge cannot resolve "
  412. "its atom; estimating dependency counts as installed-only\n"
  413. % name)
  414. counts = (1, 1)
  415. package_counts[name] = counts
  416. not_found = []
  417. for ref in refs:
  418. name = resolved_ref.get(ref)
  419. if name is None or package_counts[name] is None:
  420. not_found.append(ref)
  421. packages = []
  422. for name in sorted(candidates):
  423. if package_counts[name] is None:
  424. continue
  425. dependency_count, installed_dependency_count = package_counts[name]
  426. packages.append({
  427. "name": name,
  428. "resources": candidates[name]["resources"],
  429. "dependency-count": dependency_count,
  430. "installed-dependency-count": installed_dependency_count,
  431. })
  432. sys.stdout.write(json.dumps({
  433. "not-found": not_found,
  434. "packages": packages,
  435. }) + "\n")
  436. sys.stdout.flush()
  437. return EXIT_OK
  438. EMERGING_LINE = re.compile(r">>> Emerging \((\d+) of (\d+)\) (\S+)")
  439. INSTALLING_LINE = re.compile(r">>> Installing \((\d+) of (\d+)\) (\S+)")
  440. def classify_failure(output):
  441. """Map an emerge failure transcript onto a contract exit code."""
  442. lowered = output.lower()
  443. if ("all ebuilds that could satisfy" in lowered
  444. or "dependency conflict" in lowered
  445. or "conflicting requests" in lowered
  446. or "blocked" in lowered
  447. or "no ebuilds to satisfy" in lowered):
  448. return EXIT_RESOLVE
  449. if ("couldn't download" in lowered
  450. or "fetch instructions" in lowered
  451. or "failed to fetch" in lowered):
  452. return EXIT_DOWNLOAD
  453. return EXIT_TRANSACTION
  454. NOISE_LINE = re.compile(r"^\s*\*\s+(IMPORTANT:|Use eselect news)")
  455. def failure_message(lines, limit=15):
  456. """Last few meaningful output lines as a terminal error message."""
  457. meaningful = [line.strip() for line in lines
  458. if line.strip() and not NOISE_LINE.match(line)]
  459. tail = meaningful[-limit:]
  460. message = " | ".join(tail)
  461. return message[:800] if message else "emerge exited non-zero"
  462. # Gentoo installs some tool binaries under versioned names only (vala's
  463. # ebuild ships valac-0.56 without an unversioned valac), while USM
  464. # manifests reference the unversioned bin:. After a transaction that
  465. # installed one of these atoms, link each unversioned name to the newest
  466. # versioned binary so bin: refs resolve on the filesystem.
  467. UNVERSIONED_TOOL_LINKS = {
  468. "dev-lang/vala": ["valac", "vala-gen-introspect", "vapigen"],
  469. }
  470. def link_unversioned_tools(atoms):
  471. """Ensure unversioned tool symlinks exist for versioned ebuild tools."""
  472. wanted = set()
  473. for atom in atoms:
  474. for prefix, tools in UNVERSIONED_TOOL_LINKS.items():
  475. if atom == prefix or atom.startswith(prefix + ":"):
  476. wanted.update(tools)
  477. for tool in sorted(wanted):
  478. target = os.path.join("/usr/bin", tool)
  479. if os.path.exists(target):
  480. continue
  481. candidates = sorted(glob.glob("/usr/bin/%s-[0-9]*" % tool))
  482. if candidates:
  483. try:
  484. os.symlink(os.path.basename(candidates[-1]), target)
  485. except OSError:
  486. pass
  487. def cmd_install(args):
  488. returncode, stdout, stderr = emerge_pretend(args.names)
  489. if returncode != 0:
  490. fail("dependency resolution failed: %s"
  491. % failure_message((stdout + "\n" + stderr).splitlines()),
  492. EXIT_RESOLVE)
  493. total, _, atoms = parse_merge_list(stdout)
  494. emit_event({"type": "begin", "total": total})
  495. if args.test:
  496. emit_event({"type": "complete", "status": "ok", "installed": 0})
  497. return EXIT_OK
  498. command = [EMERGE, "--ask=n", "--quiet=y"] + list(args.names)
  499. try:
  500. proc = subprocess.Popen(
  501. command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
  502. text=True, errors="replace", bufsize=1)
  503. except OSError as e:
  504. fail("could not run emerge: %s" % e, EXIT_FAILURE)
  505. transcript = []
  506. def drain_stderr():
  507. for line in proc.stderr:
  508. transcript.append(line)
  509. sys.stderr.write(line)
  510. sys.stderr.flush()
  511. reader = threading.Thread(target=drain_stderr)
  512. reader.daemon = True
  513. reader.start()
  514. completed = []
  515. for line in proc.stdout:
  516. transcript.append(line)
  517. sys.stderr.write(line)
  518. sys.stderr.flush()
  519. emerging = EMERGING_LINE.match(line)
  520. if emerging:
  521. emit_event({
  522. "type": "package",
  523. "name": category_package(emerging.group(3)),
  524. "current": int(emerging.group(1)),
  525. "total": int(emerging.group(2)),
  526. "progress": 0.0,
  527. })
  528. continue
  529. installing = INSTALLING_LINE.match(line)
  530. if installing:
  531. name = category_package(installing.group(3))
  532. completed.append(name)
  533. emit_event({"type": "package-complete", "name": name})
  534. returncode = proc.wait()
  535. reader.join(timeout=5)
  536. if returncode != 0:
  537. fail("emerge failed (%s): %s" % (returncode, failure_message(transcript)),
  538. classify_failure("".join(transcript)))
  539. link_unversioned_tools(args.names)
  540. emit_event({
  541. "type": "complete",
  542. "status": "ok",
  543. "installed": len(completed),
  544. })
  545. return EXIT_OK
  546. def main():
  547. parser = argparse.ArgumentParser(
  548. prog="usm-spm-emerge",
  549. description="USM system-package-manager helper for Portage (Gentoo)")
  550. subparsers = parser.add_subparsers(dest="command", required=True)
  551. query_parser = subparsers.add_parser(
  552. "query", help="resolve USM resource refs to system packages")
  553. query_parser.add_argument(
  554. "refs", nargs="+", metavar="USM-REF",
  555. help="resource ref, e.g. bin:valac or pc:glib-2.0.pc")
  556. query_parser.set_defaults(handler=cmd_query)
  557. install_parser = subparsers.add_parser(
  558. "install", help="install system packages, streaming progress events")
  559. install_parser.add_argument(
  560. "--test", action="store_true",
  561. help="resolve only (emerge --pretend); no packages are built")
  562. install_parser.add_argument(
  563. "names", nargs="+", metavar="ATOM",
  564. help="native gentoo package atom, e.g. dev-build/meson")
  565. install_parser.set_defaults(handler=cmd_install)
  566. args = parser.parse_args()
  567. try:
  568. return args.handler(args)
  569. except HelperError as e:
  570. fail(str(e), e.exit_code)
  571. except SystemExit:
  572. raise
  573. except Exception as e:
  574. message = "unexpected failure: %s" % e
  575. if args.command == "install":
  576. fail(message, EXIT_FAILURE)
  577. fail_plain(message, EXIT_FAILURE)
  578. if __name__ == "__main__":
  579. sys.exit(main())