Files
NODEDC_MISSION_CORE/plugins/vesc/packaging/native_bundle.py
T

96 lines
5.8 KiB
Python

"""Bundle an admitted native engine and the actual ELF dependency closure.
Private, installer-owned Qt runtime; never installs packages on the build host.
The target is Ubuntu 24.04 amd64. Only its glibc family stays a host prerequisite.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import tarfile
SYSTEM = {"libc.so.6", "libm.so.6", "libpthread.so.0", "libdl.so.2", "librt.so.1", "libresolv.so.2", "ld-linux-x86-64.so.2"}
def build(engine, sysroot, source, output):
output.mkdir(mode=0o700, exist_ok=False)
staging = output / "payload"
(staging / "bin").mkdir(parents=True)
(staging / "lib").mkdir()
(staging / "plugins/platforms").mkdir(parents=True)
binary = staging / "bin/mission-core-vesc-engine"
shutil.copyfile(engine, binary); binary.chmod(0o755)
qtlib = sysroot / "usr/lib/x86_64-linux-gnu"
plugin = qtlib / "qt5/plugins/platforms/libqoffscreen.so"
shutil.copyfile(plugin, staging / "plugins/platforms/libqoffscreen.so")
env = dict(os.environ, LD_LIBRARY_PATH=str(qtlib), LC_ALL="C")
sources = {}
for executable in (engine, plugin):
result = subprocess.run(["/usr/bin/ldd", str(executable)], env=env, capture_output=True, text=True, check=True).stdout
if "not found" in result: raise RuntimeError("Native runtime dependency missing")
for name, path in re.findall(r"^\s*(\S+) => (/[\S]+) \(", result, re.MULTILINE):
if name in SYSTEM: continue
library = Path(path)
if name in sources and sources[name] != library: raise RuntimeError("Conflicting dependency")
sources[name] = library
for name, library in sources.items(): shutil.copyfile(library, staging / "lib" / name)
env.update(LD_LIBRARY_PATH=str(staging / "lib"), QT_PLUGIN_PATH=str(staging / "plugins"),
QT_QPA_PLATFORM="offscreen", XDG_CONFIG_HOME=str(output / "config"), XDG_CACHE_HOME=str(output / "cache"))
# All non-glibc ELF dependencies must now resolve inside the shipped payload.
for executable in (binary, staging / "plugins/platforms/libqoffscreen.so"):
result = subprocess.run(["/usr/bin/ldd", str(executable)], env=env, capture_output=True, text=True, check=True).stdout
if "not found" in result: raise RuntimeError("Bundled native closure incomplete")
for name, path in re.findall(r"^\s*(\S+) => (/[\S]+) \(", result, re.MULTILINE):
if name not in SYSTEM and not Path(path).resolve().is_relative_to(staging):
raise RuntimeError("Undeclared host dependency: " + name)
proc = subprocess.run([str(binary), "--offline"], env=env, input=b'{"id":1,"method":"engine"}\n',
capture_output=True, timeout=15, check=True)
responses = [json.loads(line) for line in proc.stdout.splitlines()]
if len(responses) != 2 or not responses[0]["ready"] or responses[1]["result"]["connected"]:
raise RuntimeError("Bundled engine acceptance failed")
(output / "offline.stdout").write_bytes(proc.stdout)
(output / "offline.stderr").write_bytes(proc.stderr)
(staging / "licenses").mkdir()
shutil.copyfile(source / "LICENSE", staging / "licenses/VESC-Tool-LICENSE")
(staging / "licenses/UPSTREAM-SOURCE.txt").write_text(
"VESC Tool 7.00, unmodified upstream sources and resources:\n"
"https://github.com/vedderb/vesc_tool/tree/01d5f10901116c311e3fb84d5a1541f663d3ce20\n"
"Source archive SHA-256: 4565ecec4e41e094bcc43127a51970e810aa9080a9d7c025af64632bfd4c0189\n"
"The process adapter source is included alongside this notice.\n")
for name in ("engine_main.cpp", "config_export.h"):
shutil.copyfile(engine.parent / name, staging / "licenses" / name)
# Preserve dependency notices available in the private signed-package sysroot.
for index, path in enumerate(sorted((sysroot / "usr/share/doc").glob("*/copyright"))):
shutil.copyfile(path, staging / "licenses" / (path.parent.name + ".copyright"))
# Host libraries copied into the closure retain their distribution notices.
for library in sources.values():
if library.resolve().is_relative_to(sysroot): continue
owner = subprocess.run(["dpkg-query", "-S", str(library)], capture_output=True, text=True)
if owner.returncode: continue
package = owner.stdout.split(": ", 1)[0].split(":", 1)[0]
notice = Path("/usr/share/doc") / package / "copyright"
if notice.is_file(): shutil.copyfile(notice, staging / "licenses" / (package + ".copyright"))
metadata = {str(p.relative_to(staging)): {"sha256": hashlib.sha256(p.read_bytes()).hexdigest(), "bytes":p.stat().st_size}
for p in sorted(staging.rglob("*")) if p.is_file()}
archive = output / "mission-core-vesc-native-runtime.tar.gz"
with tarfile.open(archive, "w:gz") as stream:
for path in sorted(staging.rglob("*")):
if path.is_file(): stream.add(path, arcname=str(path.relative_to(staging)))
report = {"schema":"missioncore.vesc.native-runtime/v1", "upstream_version":"7.00",
"upstream_commit":"01d5f10901116c311e3fb84d5a1541f663d3ce20", "os":"ubuntu-24.04-amd64",
"file":archive.name, "bytes":archive.stat().st_size, "sha256":hashlib.sha256(archive.read_bytes()).hexdigest(),
"engine_sha256":hashlib.sha256(binary.read_bytes()).hexdigest(), "files":metadata,
"host_libraries":sorted(SYSTEM), "offline_verified":True, "hardware_qualified":False,
"clean_os_qualified":False}
(output / "bundle.json").write_text(json.dumps(report,indent=2)+"\n")
if __name__ == "__main__":
parser=argparse.ArgumentParser()
for name in ("engine","sysroot","source","output"):parser.add_argument("--"+name,type=Path,required=True)
args=parser.parse_args();build(args.engine,args.sysroot,args.source,args.output)