mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-07 13:01:11 +03:00
Drm deb in release workflow (#15776)
* docs(agents): add a comment-length rule Comments were growing to document rejected alternatives, past bugs and measurements. That belongs in the commit message, not the source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ci(drm): build the unattended-wayland deb in the release workflow The deb was built by a separate drm-capture workflow on a plain runner, so it diverged from every other Linux deb: different base, different vcpkg/ffmpeg, different toolchain. Move it into flutter-build.yml as build-rustdesk-linux-drm, mirroring build-rustdesk-linux's x86_64 path -- same ubuntu18.04 container, same vcpkg install, same rust and flutter. libdrmtap is built on the runner first and handed to the container via DRMTAP_PREBUILT_DIR, because bionic's meson is too old to build it. The job is ungated, so the --drm packaging path is exercised on every PR; only publishing stays gated on upload-artifact. drm-capture.yml is deleted along with docs/DRM_CAPTURE_SECURITY.md -- the 29 drm unit tests that workflow ran are no longer executed by CI. Three bugs the move exposed: - build.py anchored the libdrmtap paths on abspath(__file__), which is only cwd-independent on Python >= 3.9 (bpo-20443). The packaging container runs 3.6 and chdir's into flutter/, so the ABI-gate cross-check resolved one directory off and every --drm packaging run would have died with FileNotFoundError. Captured as REPO_ROOT at import instead. - DRMTAP_PREBUILT_DIR no longer needs DRMTAP_ALLOW_UNPINNED. A prebuilt dir inside the repo's own third_party/libdrmtap at the pinned sha is the pinned object, not an override, and is now verified as such. - The variant's Depends carried a bare libdrm2. libdrmtap needs drmModeGetFB2, so it is libdrm2 (>= 2.4.95); below that the package installed and could never capture. The loader also logs the dlerror now instead of discarding it, so a soname or glibc mismatch is named rather than surfacing as a generic "libdrmtap not available". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(drm): declare the unattended-wayland deb's real libc6 and libdrm floors libdrmtap is built on the ubuntu-22.04 runner while the rest of the deb comes from the ubuntu18.04 container, so the package has a mixed glibc floor and declared neither half. It installed happily on Ubuntu 20.04 / Debian 11 (glibc 2.31), then dlopen failed on GLIBC_2.34 and capture degraded to the PipeWire portal -- the one thing this variant exists to avoid. Measure the floor off the staged objects and put it in Depends, so apt refuses with a reason instead of handing over a package that can never capture. Measured rather than written down: the number moves whenever either base does, and it lands exactly on RHEL/Rocky 9 (glibc 2.34), where one off-by-one decides whether that whole family can install. drmModeGetFB2 landed in libdrm 2.4.101, not 2.4.95 -- checked against the libdrm tags, xf86drmMode.h first declares it in 2.4.101. The old floor admitted Debian 10 (2.4.97), where the .so is linked -z now and dies on an undefined symbol at dlopen. libdrmtap's own meson.build carries the same wrong number. Upload the deb on always(): the run that fails the drm check is the one whose artifact is most worth downloading. Publish stays gated on success, so an unverified build still cannot reach a release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
86
build.py
86
build.py
@@ -15,6 +15,11 @@ import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Captured at import, while cwd is still the repo root: before Python 3.9 the main script's __file__
|
||||
# stays relative (bpo-20443), so abspath() re-resolves it against the cwd -- and the ubuntu18.04
|
||||
# packaging container runs 3.6 and chdir's into flutter/ before it reaches the libdrmtap code.
|
||||
REPO_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
windows = platform.platform().startswith('Windows')
|
||||
osx = platform.platform().startswith(
|
||||
'Darwin') or platform.platform().startswith("macOS")
|
||||
@@ -327,8 +332,8 @@ def get_features(args):
|
||||
# straight from `target/release` without bundling libdrmtap, without the rename, without
|
||||
# Conflicts/Provides and without assert_staged_binary_is_drm() -- so they would emit a
|
||||
# package NAMED `rustdesk` carrying the consent-bypass backend and the root-side uinput
|
||||
# injection. The separate package name is the informed consent this feature rests on (see
|
||||
# docs/DRM_CAPTURE_SECURITY.md), so refuse rather than ship a stock-named build of it.
|
||||
# injection. The separate package name is the informed consent this feature rests on, so
|
||||
# refuse rather than ship a stock-named build of it.
|
||||
branch = linux_packaging_branch()
|
||||
if branch != 'deb':
|
||||
raise Exception(
|
||||
@@ -399,20 +404,40 @@ LIBDRMTAP_SHA = os.environ.get('DRMTAP_SHA', LIBDRMTAP_SHA_PINNED)
|
||||
DRMTAP_UNPINNED_OK = os.environ.get('DRMTAP_ALLOW_UNPINNED') == '1'
|
||||
|
||||
|
||||
def _prebuilt_dir_is_the_pinned_checkout(prebuilt_dir):
|
||||
# A .so built from this repo's own third_party/libdrmtap at the pinned sha is the pinned object,
|
||||
# not an override, so it must not need the opt-in. This is how CI hands the library from a step
|
||||
# that has meson to a packaging container that does not.
|
||||
src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap')
|
||||
try:
|
||||
inside = os.path.commonpath([os.path.abspath(prebuilt_dir), src]) == src
|
||||
except ValueError:
|
||||
return False
|
||||
if not inside or not os.path.isdir(os.path.join(src, '.git')):
|
||||
return False
|
||||
try:
|
||||
head = subprocess.check_output(['git', '-C', src, 'rev-parse', 'HEAD']).decode().strip()
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
return head == LIBDRMTAP_SHA
|
||||
|
||||
|
||||
def _validate_libdrmtap_pin():
|
||||
# Called from build_libdrmtap_so(), NOT at import: a stock (non --drm) build must stay
|
||||
# byte-identical to upstream in behaviour too, and leftover DRMTAP_* variables in the
|
||||
# environment (or a malformed sha) must not be able to fail a build that never touches
|
||||
# libdrmtap.
|
||||
# `or None` so an empty value reads as unset here exactly as it does in build_libdrmtap_so(),
|
||||
# which tests it for truthiness.
|
||||
prebuilt = os.environ.get('DRMTAP_PREBUILT_DIR') or None
|
||||
if prebuilt and _prebuilt_dir_is_the_pinned_checkout(prebuilt):
|
||||
prebuilt = None
|
||||
overridden = [
|
||||
name
|
||||
for name, value, pinned in (
|
||||
('DRMTAP_REPO', LIBDRMTAP_REPO, LIBDRMTAP_REPO_PINNED),
|
||||
('DRMTAP_SHA', LIBDRMTAP_SHA, LIBDRMTAP_SHA_PINNED),
|
||||
# `or None` so an empty value reads as unset here exactly as it does in
|
||||
# build_libdrmtap_so(), which tests it for truthiness. Otherwise `DRMTAP_PREBUILT_DIR=`
|
||||
# would demand the opt-in for an override that is not going to happen.
|
||||
('DRMTAP_PREBUILT_DIR', os.environ.get('DRMTAP_PREBUILT_DIR') or None, None),
|
||||
('DRMTAP_PREBUILT_DIR', prebuilt, None),
|
||||
)
|
||||
if value != pinned
|
||||
]
|
||||
@@ -452,7 +477,6 @@ def build_libdrmtap_so():
|
||||
# library target is built (the source also carries a helper binary we do not
|
||||
# ship). Returns the path to the built versioned .so (e.g. libdrmtap.so.0.4.x).
|
||||
_validate_libdrmtap_pin()
|
||||
repo_root = os.path.dirname(os.path.abspath(__file__))
|
||||
# Allow a caller (e.g. CI) to build the .so ahead of time and hand it in via
|
||||
# DRMTAP_PREBUILT_DIR (must contain the real libdrmtap.so.0.* object).
|
||||
prebuilt_dir = os.environ.get('DRMTAP_PREBUILT_DIR')
|
||||
@@ -474,7 +498,7 @@ def build_libdrmtap_so():
|
||||
# `main` the pinned commit is not in the shallow clone at all and the build fails on an unreachable
|
||||
# object. Fetching the sha needs no branch name, so it keeps working across every upstream push and
|
||||
# is immune to a ref being moved or repointed.
|
||||
src = os.path.join(repo_root, 'third_party', 'libdrmtap')
|
||||
src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap')
|
||||
if not os.path.exists(os.path.join(src, 'meson.build')):
|
||||
if os.path.isdir(src):
|
||||
shutil.rmtree(src)
|
||||
@@ -526,7 +550,7 @@ def _assert_so_has_egl(so_path):
|
||||
# EGL is reached by lazy dlopen, on purpose, so that the privileged service never links the GPU
|
||||
# stack. That means there is no DT_NEEDED to look for and an ELF-level check reports "no EGL" on a
|
||||
# perfectly good library; the dlopen name and an extension symbol are what a CPU-only stub really
|
||||
# lacks. Same two markers the drm-capture workflow asserts in CI.
|
||||
# lacks.
|
||||
try:
|
||||
with open(so_path, 'rb') as f:
|
||||
blob = f.read()
|
||||
@@ -566,11 +590,8 @@ def assert_so_satisfies_the_runtime_abi_gate(so_path):
|
||||
print(f'[drm] cannot read a version out of {so_path}; skipping the ABI-gate cross-check')
|
||||
return
|
||||
so_ver = tuple(int(g) for g in m.groups())
|
||||
# Anchored on THIS file, not on the cwd: both callers of stage_libdrmtap_into_deb have already
|
||||
# chdir'd into flutter/ by the time they get here, so a cwd-relative path raises FileNotFoundError
|
||||
# and fails every --drm packaging run. (It did; CI caught it.)
|
||||
gate_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
'libs', 'scrap', 'src', 'common', 'drmtap_dl.rs')
|
||||
# REPO_ROOT, not abspath(__file__): both callers have chdir'd into flutter/ by now.
|
||||
gate_path = os.path.join(REPO_ROOT, 'libs', 'scrap', 'src', 'common', 'drmtap_dl.rs')
|
||||
with open(gate_path) as f:
|
||||
gate_src = f.read()
|
||||
|
||||
@@ -613,6 +634,37 @@ def stage_libdrmtap_into_deb(so_path):
|
||||
system2(f'ln -sf "{so_basename}" tmpdeb/usr/lib/rustdesk/libdrmtap.so.0')
|
||||
|
||||
|
||||
def _max_glibc_minor(path):
|
||||
# Read from .dynstr rather than via objdump so packaging needs no binutils; chunked because
|
||||
# librustdesk.so is ~45 MB.
|
||||
best = 0
|
||||
with open(path, 'rb') as f:
|
||||
tail = b''
|
||||
while True:
|
||||
chunk = f.read(1 << 20)
|
||||
if not chunk:
|
||||
return best
|
||||
for m in re.finditer(rb'GLIBC_2\.(\d+)', tail + chunk):
|
||||
best = max(best, int(m.group(1)))
|
||||
tail = chunk[-16:]
|
||||
|
||||
|
||||
def measured_glibc_floor():
|
||||
# libdrmtap is built on a newer base than the rest of the deb, so the floor is whichever staged
|
||||
# object is higher -- and it moves whenever either base does.
|
||||
paths = [p for p in glob.glob('tmpdeb/usr/lib/rustdesk/libdrmtap.so.0.*')
|
||||
+ glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so')
|
||||
+ glob.glob('tmpdeb/usr/share/rustdesk/rustdesk')
|
||||
if os.path.isfile(p) and not os.path.islink(p)]
|
||||
minor = max((_max_glibc_minor(p) for p in paths), default=0)
|
||||
if not minor:
|
||||
raise Exception(
|
||||
f'could not measure a GLIBC_2.x floor from any staged object ({paths or "none found"}); '
|
||||
'refusing to ship the unattended-wayland variant with an undeclared libc6 floor, which '
|
||||
'is what lets it install on a host where libdrmtap can never load')
|
||||
return f'2.{minor}'
|
||||
|
||||
|
||||
def retarget_control_to_drm_variant():
|
||||
# Rewrite the control file that generate_control_file just produced, instead of parameterizing that
|
||||
# function: the stock packaging path stays exactly as upstream wrote it, and everything specific to
|
||||
@@ -620,6 +672,8 @@ def retarget_control_to_drm_variant():
|
||||
# conflict with and replace it: you install one or the other, never both. It also needs libdrmtap's
|
||||
# own runtime deps, which the stock package has no reason to carry.
|
||||
path = '../res/DEBIAN/control'
|
||||
floor = measured_glibc_floor()
|
||||
print(f'[drm] {DRM_PACKAGE_NAME} libc6 floor measured at {floor}')
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
out = []
|
||||
@@ -628,7 +682,9 @@ def retarget_control_to_drm_variant():
|
||||
out.append(f'Package: {DRM_PACKAGE_NAME}\n')
|
||||
out.append('Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n')
|
||||
elif line.startswith('Depends:'):
|
||||
out.append(line.rstrip('\n') + ', libdrm2, libegl1, libgles2\n')
|
||||
# 2.4.101 is where drmModeGetFB2 landed; below it libdrmtap loads and can never capture.
|
||||
out.append(line.rstrip('\n') + ', libdrm2 (>= 2.4.101), libegl1, libgles2, '
|
||||
f'libc6 (>= {floor})\n')
|
||||
else:
|
||||
out.append(line)
|
||||
body = ''.join(out)
|
||||
|
||||
Reference in New Issue
Block a user