The plane added three runtime libraries that bundle-libs.sh deliberately does not bundle, so they have to be declared per distro by hand - and two hand-maintained lists drifting apart is the failure this guard exists to prevent. check_linux_package_deps.py parses the runner's CMake for every pkg-config module it links, follows target_link_libraries to prove each one actually reaches the binary, and requires a package name for it in every distro's depends list. It fails closed on the shapes a naive parser gets wrong: a pkg_check_modules call naming several modules, options preceding the module name, and version constraints like mpv>=0.40 that would otherwise be read as a package nobody ships. The smoke job builds the three packages and reads the dependencies back out of the artifacts, deriving what to expect from build-packages.py rather than restating it - so a library is declared once and verified everywhere. That job is off by default, which is exactly why it must not carry its own copy of the list. The Linux native job names libwayland-dev and libegl-dev instead of riding GTK's and epoxy's transitive dev dependencies, matching the CMake comment's own rationale. In CI the host-dependency guard runs once: the named step covers the staged bundle, and build-packages.py's internal run - which exists for by-hand packaging - is skipped. The smoke job also drops patchelf, which nothing invokes.
283 lines
14 KiB
Python
283 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Check that every library the built bundle needs from the host is declared.
|
|
|
|
bundle-libs.sh ships most of what the app links, but deliberately leaves the
|
|
graphics stack, the audio stack and the C runtime to the host: those are coupled
|
|
to the running compositor, the GPU driver and the kernel, so a bundled copy is
|
|
worse than useless. Everything it leaves behind has to be named as a package
|
|
dependency instead. Miss one and the loader fails before main() runs - which is
|
|
invisible to any amount of reading package metadata, and invisible to CI unless
|
|
something derives the requirement from the artifact.
|
|
|
|
That matters more than it used to. libmpv now travels inside the package instead
|
|
of being depended on, because the plane needs the pinned Wayland-enabled build.
|
|
The host `mpv` dependency that went away had been quietly providing libva, libdrm
|
|
and their friends transitively.
|
|
|
|
The deb answer is derived, not written down: dpkg owns the authoritative mapping
|
|
from a file to the package providing it, so a library that appears in the bundle
|
|
cannot slip past by being absent from a list someone forgot to update. rpm and
|
|
pacman names cannot be resolved on a Debian-family runner, so those stay
|
|
recorded below; their job is to fail loudly when a new library lands and nobody
|
|
thought about Fedora and Arch.
|
|
|
|
Coverage is checked in one direction only. A declared dependency that nothing
|
|
links is harmless - it installs a package the user probably has. A linked
|
|
library that nothing declares is a broken install, so that is the direction
|
|
worth failing on.
|
|
"""
|
|
|
|
import argparse
|
|
import importlib.util
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# The checkout this script lives in. Derived from its own location rather than
|
|
# the working directory, so build-packages.py is still found when CI invokes
|
|
# the check from the build tree; --root stays as the override.
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
# Sonames provided by the base system on every distro. No package declares a
|
|
# dependency on the C runtime or the dynamic loader; they are the floor that
|
|
# has to exist for dpkg itself to run.
|
|
BASELINE_PREFIXES = (
|
|
"linux-vdso.so",
|
|
"ld-linux",
|
|
"libc.so",
|
|
"libm.so",
|
|
"libpthread.so",
|
|
"libdl.so",
|
|
"librt.so",
|
|
"libmvec.so",
|
|
"libresolv.so",
|
|
"libnss_",
|
|
)
|
|
|
|
# Where the loader actually looks for a soname, relative to the bundle root:
|
|
# linux/CMakeLists.txt gives plezy RPATH $ORIGIN/lib and plezy.sh exports
|
|
# $INSTALL_DIR/lib. Nothing in the bundle carries a RUNPATH of its own, so this
|
|
# one directory is the whole search path for every object in it.
|
|
LOADER_DIRECTORIES = ("lib",)
|
|
|
|
# The non-deb package that provides each host soname. Fedora and Arch cannot be
|
|
# queried from here, so this is the one place where those names are asserted
|
|
# rather than derived - and an unlisted soname is an error, so adding a library
|
|
# forces the decision instead of silently shipping a package that cannot start.
|
|
OTHER_DISTROS = {
|
|
"libEGL.so.1": {"rpm": "libglvnd-egl", "pacman": "libglvnd"},
|
|
"libGL.so.1": {"rpm": "libglvnd-glx", "pacman": "libglvnd"},
|
|
# Fedora splits glvnd by API: -egl, -gles, -glx and -opengl sit over the
|
|
# base libglvnd that carries libGLdispatch, so GLES is not the EGL package.
|
|
"libGLESv2.so.2": {"rpm": "libglvnd-gles", "pacman": "libglvnd"},
|
|
"libGLX.so.0": {"rpm": "libglvnd-glx", "pacman": "libglvnd"},
|
|
"libGLdispatch.so.0": {"rpm": "libglvnd", "pacman": "libglvnd"},
|
|
"libOpenGL.so.0": {"rpm": "libglvnd-opengl", "pacman": "libglvnd"},
|
|
"libX11-xcb.so.1": {"rpm": "libX11-xcb", "pacman": "libx11"},
|
|
"libX11.so.6": {"rpm": "libX11", "pacman": "libx11"},
|
|
"libXext.so.6": {"rpm": "libXext", "pacman": "libxext"},
|
|
"libasound.so.2": {"rpm": "alsa-lib", "pacman": "alsa-lib"},
|
|
"libdrm.so.2": {"rpm": "libdrm", "pacman": "libdrm"},
|
|
"libepoxy.so.0": {"rpm": "libepoxy", "pacman": "libepoxy"},
|
|
"libgbm.so.1": {"rpm": "mesa-libgbm", "pacman": "mesa"},
|
|
"libva-drm.so.2": {"rpm": "libva", "pacman": "libva"},
|
|
"libva-wayland.so.2": {"rpm": "libva", "pacman": "libva"},
|
|
"libva-x11.so.2": {"rpm": "libva", "pacman": "libva"},
|
|
"libva.so.2": {"rpm": "libva", "pacman": "libva"},
|
|
"libvdpau.so.1": {"rpm": "libvdpau", "pacman": "libvdpau"},
|
|
"libvulkan.so.1": {"rpm": "vulkan-loader", "pacman": "vulkan-icd-loader"},
|
|
"libwayland-client.so.0": {"rpm": "libwayland-client", "pacman": "wayland"},
|
|
"libwayland-cursor.so.0": {"rpm": "libwayland-cursor", "pacman": "wayland"},
|
|
"libwayland-egl.so.1": {"rpm": "libwayland-egl", "pacman": "wayland"},
|
|
"libwayland-server.so.0": {"rpm": "libwayland-server", "pacman": "wayland"},
|
|
"libxcb-dri3.so.0": {"rpm": "libxcb", "pacman": "libxcb"},
|
|
"libxcb-present.so.0": {"rpm": "libxcb", "pacman": "libxcb"},
|
|
"libxcb-randr.so.0": {"rpm": "libxcb", "pacman": "libxcb"},
|
|
"libxcb-render.so.0": {"rpm": "libxcb", "pacman": "libxcb"},
|
|
"libxcb-shm.so.0": {"rpm": "libxcb", "pacman": "libxcb"},
|
|
"libxcb-sync.so.1": {"rpm": "libxcb", "pacman": "libxcb"},
|
|
"libxcb-xfixes.so.0": {"rpm": "libxcb", "pacman": "libxcb"},
|
|
"libxcb.so.1": {"rpm": "libxcb", "pacman": "libxcb"},
|
|
}
|
|
|
|
|
|
# ldd and dpkg-query are the entire evidence base for this check, so they have
|
|
# to be substitutable: PLEZY_HOST_TOOLS names a directory searched ahead of
|
|
# PATH, which is what lets scripts/test_check_bundle_host_deps.py drive real
|
|
# runs of this script against a synthetic bundle on any machine.
|
|
HOST_TOOLS = os.environ.get("PLEZY_HOST_TOOLS")
|
|
|
|
|
|
def locate(program: str) -> str | None:
|
|
"""Where `program` resolves, PLEZY_HOST_TOOLS ahead of PATH."""
|
|
override = shutil.which(program, path=HOST_TOOLS) if HOST_TOOLS else None
|
|
return override or shutil.which(program)
|
|
|
|
|
|
def run(*command: str) -> subprocess.CompletedProcess:
|
|
program, *arguments = command
|
|
return subprocess.run([locate(program) or program, *arguments], capture_output=True, text=True)
|
|
|
|
|
|
def load_distros(root: Path) -> dict:
|
|
"""Read DISTROS out of build-packages.py rather than duplicating it."""
|
|
spec = importlib.util.spec_from_file_location("build_packages", root / "linux/packaging/build-packages.py")
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module.DISTROS
|
|
|
|
|
|
def host_libraries(bundle: Path) -> tuple[dict[str, str], list[str]]:
|
|
"""soname -> host path for what the bundle resolves outside itself.
|
|
|
|
Also returns problems worth failing on. An unresolved entry is the loudest
|
|
possible version of the failure this whole check exists to prevent - the
|
|
loader finding nothing at all - so it must never be quietly skipped for not
|
|
looking like a path.
|
|
"""
|
|
# Only what the loader will search counts as shipped. The dlopen'd
|
|
# gdk-pixbuf loaders and GIO modules below lib/ are opened by explicit path
|
|
# and never resolve a soname for anyone, so a nested module whose filename
|
|
# happens to equal one the executable really takes from the host would
|
|
# otherwise make that host library look bundled and pass the check
|
|
# vacuously.
|
|
shipped = {path.name for directory in LOADER_DIRECTORIES for path in (bundle / directory).glob("*.so*")}
|
|
# rglob for the objects to inspect, though: those same modules pull in host
|
|
# libraries nothing else in the bundle links, and scanning only lib/*.so*
|
|
# left their dependencies out of ldd entirely.
|
|
binaries = [bundle / "plezy", *sorted(bundle.rglob("*.so*"))]
|
|
needed: dict[str, str] = {}
|
|
problems: list[str] = []
|
|
for binary in binaries:
|
|
result = run("ldd", str(binary))
|
|
# ldd exits non-zero for a file it cannot read as an object. Statically
|
|
# linked objects are the benign case and say so on stdout.
|
|
if result.returncode != 0 and "not a dynamic executable" not in (result.stdout + result.stderr):
|
|
problems.append(f"ldd could not read {binary}: {result.stderr.strip() or 'no diagnostic'}")
|
|
continue
|
|
for line in result.stdout.splitlines():
|
|
soname, separator, remainder = line.partition("=>")
|
|
soname = soname.strip()
|
|
remainder = remainder.strip()
|
|
if not separator or not soname:
|
|
continue
|
|
if remainder.startswith("not found"):
|
|
# Unless the bundle ships it. Bundled objects carry no RUNPATH of
|
|
# their own - bundle-libs.sh copies and strips, it does not
|
|
# patchelf - so running ldd on one in isolation resolves its
|
|
# DT_NEEDED entries against the system cache only, and a library
|
|
# that exists nowhere but the bundle reads as missing. At runtime
|
|
# the executable's own $ORIGIN/lib finds it, which is what makes
|
|
# shipping it sufficient. libshaderc_shared is exactly this case:
|
|
# no distro package installs it, which is why both workflows copy
|
|
# it in by hand.
|
|
if soname in shipped:
|
|
continue
|
|
problems.append(f"{binary.name} needs {soname}, which resolves to nothing on this machine")
|
|
continue
|
|
path = remainder.split(" (")[0]
|
|
if not path.startswith("/"):
|
|
continue
|
|
# A library that ships beside the binary needs nothing declared,
|
|
# even when the loader happened to resolve this copy from the host.
|
|
if soname in shipped or soname.startswith(BASELINE_PREFIXES):
|
|
continue
|
|
needed[soname] = path
|
|
return needed, problems
|
|
|
|
|
|
def deb_owner(path: str) -> str:
|
|
"""The deb package owning a file, following symlinks when dpkg needs it."""
|
|
for candidate in (path, os.path.realpath(path)):
|
|
owner = run("dpkg-query", "-S", candidate).stdout.partition(":")[0].strip()
|
|
if owner:
|
|
# A diverted or multi-arch answer can carry an architecture suffix.
|
|
return owner.split(",")[0].split(":")[0]
|
|
return ""
|
|
|
|
|
|
def deb_names(package: str) -> set[str]:
|
|
"""The package's own name plus everything it Provides."""
|
|
provides = run("dpkg-query", "-W", "-f", "${Provides}", package).stdout
|
|
names = {package}
|
|
for entry in provides.split(","):
|
|
name = entry.split("(")[0].strip()
|
|
if name:
|
|
names.add(name)
|
|
return names
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("bundle", type=Path, help="the built Flutter bundle directory")
|
|
parser.add_argument("--root", type=Path, default=PROJECT_ROOT, help="repository root")
|
|
arguments = parser.parse_args()
|
|
|
|
if not (arguments.bundle / "plezy").exists():
|
|
print(f"::error::{arguments.bundle}/plezy does not exist - nothing to check", file=sys.stderr)
|
|
return 1
|
|
|
|
# ldd and dpkg-query are the entire evidence base. Without them this walk
|
|
# finds nothing and would otherwise report a bundle with no host libraries
|
|
# at all, which is the one failure mode a guard must never have. dpkg-query
|
|
# also pins the check to a Debian-family host: Fedora and Arch cannot answer
|
|
# which package owns a file, so the check genuinely cannot run there and says
|
|
# so instead of passing. build-packages.py carries the deliberate opt-out for
|
|
# anyone who has to package from such a host anyway.
|
|
absent = [tool for tool in ("ldd", "dpkg-query") if locate(tool) is None]
|
|
if absent:
|
|
print(
|
|
f"::error::{' and '.join(absent)} not found - this check derives deb package ownership "
|
|
"from dpkg-query, so it only runs on a Debian or Ubuntu host",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
distros = load_distros(arguments.root)
|
|
# "a | b" is satisfied by either name, so compare against the flattened set.
|
|
declared = {
|
|
distro: {name.strip() for dependency in config["depends"] for name in dependency.split("|")}
|
|
for distro, config in distros.items()
|
|
}
|
|
|
|
needed, errors = host_libraries(arguments.bundle)
|
|
# A real bundle always needs *something* from the host: bundle-libs.sh
|
|
# deliberately leaves the graphics stack behind, so an empty result means the
|
|
# walk found nothing rather than that nothing is needed - a silently
|
|
# successful no-op is the worst outcome for a guard.
|
|
if not needed and not errors:
|
|
errors.append(
|
|
f"no host libraries were found for {arguments.bundle} at all, which cannot be right - "
|
|
"ldd produced nothing usable, so this proved nothing"
|
|
)
|
|
for soname, path in sorted(needed.items()):
|
|
owner = deb_owner(path)
|
|
if not owner:
|
|
errors.append(f"{soname} ({path}) belongs to no deb package, so it cannot be checked or declared")
|
|
elif not (deb_names(owner) & declared["deb"]):
|
|
errors.append(f"{soname} comes from deb package '{owner}', which linux/packaging/build-packages.py does not declare")
|
|
|
|
others = OTHER_DISTROS.get(soname)
|
|
if others is None:
|
|
errors.append(f"{soname} has no rpm/pacman package recorded in {Path(__file__).name}")
|
|
continue
|
|
for distro, package in sorted(others.items()):
|
|
if distro in declared and package not in declared[distro]:
|
|
errors.append(f"{soname} needs '{package}' on {distro}, which build-packages.py does not declare")
|
|
|
|
for error in errors:
|
|
print(f"::error::{error}", file=sys.stderr)
|
|
if errors:
|
|
return 1
|
|
|
|
print(f"every one of the {len(needed)} host libraries the bundle needs is declared on all {len(distros)} distros:")
|
|
for soname in sorted(needed):
|
|
print(f" {soname:<28} {deb_owner(needed[soname])}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|