Files
computer-playbook/utils/install/lint/mermaid_cli.py
kevinveenbirkenbachandClaude Opus 5.5 bfd6f7c9b8 fix(install-lint): give chrome-headless-shell a font on dnf, yum and pacman
On a fresh Fedora dev container lint-mermaid failed most files with
ConnectionClosedError: the browser aborted with
`FATAL:third_party/skia/src/ports/SkFontMgr_FontConfigInterface.cpp:163]
Not implemented.` in its font fallback, because the host had no font at
all. install-lint provisions the browser's libraries by soname on dnf,
yum and pacman, and a font is not a soname; apt-get gets fonts through
puppeteer's --install-deps.

It looked load-dependent because lint-packages installs rpmlint in the
same parallel phase, which pulls fontconfig and Noto Sans: renders
crashed until that transaction landed, and every later run was green.
With every font removed, lint-mermaid alone fails 274 of 289 files the
same way.

ensure() now installs the distribution's font provide when
/usr/share/fonts holds no file (`font(:lang=en)` on dnf and yum,
`ttf-font` on pacman), and browser_installed() counts the font, so the
install-lint stamp is not fresh on a font-less host.

Closes #1154

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 12:59:42 +02:00

271 lines
8.3 KiB
Python

"""Install @mermaid-js/mermaid-cli (mmdc) and the chrome-headless-shell it renders with.
mmdc comes from npm with ``PUPPETEER_SKIP_DOWNLOAD`` set; the browser is
provisioned afterwards through the puppeteer that npm installed next to mmdc,
at the revision its puppeteer-core pins. ``unzip`` is ensured first because
puppeteer extracts the browser with it. Puppeteer installs the browser's
shared libraries only through apt-get (``--install-deps``); on dnf, yum and
pacman the libraries ``ldd`` reports missing are installed by soname, and a
font by the distribution's font provide, because the browser aborts in its
font fallback (``SkFontMgr_FontConfigInterface ... Not implemented``) on a
host without any font. Every failure aborts ``install-lint``.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from utils.install.npm import npm_install_global
from utils.install.primitives import log, run_privileged, which
from utils.install.system_pkg import detect_package_manager, install_command_via_pkg
BROWSER = "chrome-headless-shell"
PROVISION_TIMEOUT_S = 900
FONT_DIRECTORY = Path("/usr/share/fonts")
_INSTALL_ARGV = {
"dnf": ["dnf", "-y", "install"],
"yum": ["yum", "-y", "install"],
"pacman": ["pacman", "-Syu", "--noconfirm", "--needed"],
}
_FONT_PROVIDES = {
"dnf": "font(:lang=en)",
"yum": "font(:lang=en)",
"pacman": "ttf-font",
}
def _ensure_unzip() -> None:
if not which("unzip"):
install_command_via_pkg("unzip")
def browser_cache() -> Path:
base = os.environ.get("PUPPETEER_CACHE_DIR") or Path.home() / ".cache" / "puppeteer"
return Path(base) / BROWSER
def _mmdc_puppeteer() -> Path | None:
mmdc = shutil.which("mmdc")
if mmdc is None:
return None
for parent in Path(mmdc).resolve().parents:
puppeteer = parent / "node_modules" / "puppeteer"
if puppeteer.is_dir():
return puppeteer
return None
def _puppeteer_core(puppeteer: Path) -> Path | None:
for base in (puppeteer, *puppeteer.parents):
if base.name == "node_modules":
continue
core = base / "node_modules" / "puppeteer-core"
if core.is_dir():
return core
return None
def browser_revision() -> str | None:
puppeteer = _mmdc_puppeteer()
if puppeteer is None:
return None
core = _puppeteer_core(puppeteer)
if core is None:
return None
revisions = core / "lib" / "puppeteer" / "revisions.js"
try:
text = revisions.read_text(encoding="utf-8") # nocheck: cache-read npm prefix
except OSError:
return None
match = re.search(rf"'{BROWSER}':\s*'([^']+)'", text)
return match.group(1) if match else None
def browser_executable() -> Path | None:
revision = browser_revision()
if revision is None:
return None
return next(
(
path
for path in browser_cache().glob(f"*-{revision}/*/{BROWSER}")
if path.is_file()
),
None,
)
def missing_libraries(executable: Path) -> list[str]:
"""Return the shared libraries the dynamic loader cannot resolve.
Args:
executable: the browser binary.
Returns:
Sorted sonames ``ldd`` reports as ``not found``.
"""
done = subprocess.run(
["ldd", str(executable)], capture_output=True, text=True, check=False
)
return sorted(
{line.split()[0] for line in done.stdout.splitlines() if "=> not found" in line}
)
def fonts_installed() -> bool:
return FONT_DIRECTORY.is_dir() and any(
path.is_file() for path in FONT_DIRECTORY.rglob("*")
)
def browser_installed() -> bool:
executable = browser_executable()
return (
executable is not None
and not missing_libraries(executable)
and fonts_installed()
)
def _provision_browser(puppeteer: Path) -> subprocess.CompletedProcess[str]:
argv = [
str(puppeteer.parent / ".bin" / "puppeteer"),
"browsers",
"install",
BROWSER,
]
if os.geteuid() == 0 and which("apt-get"):
argv.append("--install-deps")
env = {k: v for k, v in os.environ.items() if k != "PUPPETEER_SKIP_DOWNLOAD"}
return subprocess.run(
argv,
env=env,
capture_output=True,
text=True,
timeout=PROVISION_TIMEOUT_S,
check=False,
)
def _download_browser() -> None:
puppeteer = _mmdc_puppeteer()
if puppeteer is None:
raise RuntimeError(f"Cannot provision {BROWSER}: no puppeteer next to mmdc.")
log(f"Provisioning {BROWSER} for mmdc via its own puppeteer.")
done = _provision_browser(puppeteer)
if done.returncode != 0:
revision = browser_revision()
if revision is not None:
for partial in browser_cache().glob(f"*-{revision}"):
shutil.rmtree(partial, ignore_errors=True)
done = _provision_browser(puppeteer)
if done.returncode != 0:
raise RuntimeError(
f"{BROWSER} provisioning failed:\n{done.stdout}{done.stderr}"
)
def _library_packages(manager: str, sonames: list[str]) -> list[str]:
"""Map sonames to what the package manager installs them by.
Args:
manager: the detected package manager.
sonames: libraries the browser cannot resolve.
Returns:
Install arguments for ``manager``.
Raises:
RuntimeError: the manager cannot install by soname.
"""
if manager in ("dnf", "yum"):
suffix = "()(64bit)" if sys.maxsize > 2**32 else ""
return [f"{soname}{suffix}" for soname in sonames]
if manager == "pacman":
run_privileged(["pacman", "-Fy"])
packages: set[str] = set()
for soname in sonames:
found = subprocess.run(
["pacman", "-Fq", f"usr/lib/{soname}"],
capture_output=True,
text=True,
check=False,
).stdout.split()
if found:
packages.add(found[0].rsplit("/", 1)[-1])
return sorted(packages)
raise RuntimeError(
f"{BROWSER} lacks {', '.join(sonames)} and {manager} cannot install "
f"libraries by soname; on apt-get they come from --install-deps, which "
f"needs root."
)
def _ensure_browser_libraries(executable: Path) -> None:
missing = missing_libraries(executable)
if not missing:
return
manager = detect_package_manager()
packages = _library_packages(manager, missing)
log(f"Installing {BROWSER} libraries via {manager}: {', '.join(packages)}")
if packages:
run_privileged([*_INSTALL_ARGV[manager], *packages])
missing = missing_libraries(executable)
if missing:
raise RuntimeError(
f"{BROWSER} still lacks {', '.join(missing)} after installing "
f"{packages} via {manager}."
)
def _ensure_fonts() -> None:
if fonts_installed():
return
manager = detect_package_manager()
provide = _FONT_PROVIDES.get(manager)
if provide is None:
raise RuntimeError(
f"{BROWSER} finds no font under {FONT_DIRECTORY} and {manager} has "
f"no font provide; on apt-get the fonts come from --install-deps, "
f"which needs root."
)
log(f"Installing a font for {BROWSER} via {manager}: {provide}")
run_privileged([*_INSTALL_ARGV[manager], provide])
if not fonts_installed():
raise RuntimeError(
f"{BROWSER} still finds no font under {FONT_DIRECTORY} after "
f"installing {provide} via {manager}."
)
def _ensure_browser() -> None:
executable = browser_executable()
if executable is None:
_download_browser()
executable = browser_executable()
if executable is None:
raise RuntimeError(f"{BROWSER} is still missing after provisioning.")
_ensure_browser_libraries(executable)
_ensure_fonts()
def ensure() -> None:
_ensure_unzip()
if not which("mmdc"):
log("Missing command 'mmdc'. Installing @mermaid-js/mermaid-cli via npm.")
os.environ["PUPPETEER_SKIP_DOWNLOAD"] = "1"
npm_install_global("@mermaid-js/mermaid-cli")
if not which("mmdc"):
raise RuntimeError(
"Command 'mmdc' is still unavailable after installation."
)
_ensure_browser()