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

189 lines
11 KiB
Python

"""Unprivileged VESC Tool build spike; never installs packages or opens a device.
Run in a bounded user systemd scope on Ubuntu 24.04 amd64. APT resolves and
downloads signed Ubuntu packages into this job; dpkg-deb only extracts them.
The output is an engineering build, not an installed or qualified runtime.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import platform
import re
import shutil
import subprocess
import tarfile
import time
from datetime import datetime, timezone
COMMIT = "01d5f10901116c311e3fb84d5a1541f663d3ce20"
ARCHIVE_SHA256 = "4565ecec4e41e094bcc43127a51970e810aa9080a9d7c025af64632bfd4c0189"
DEPS = (
"qtbase5-dev", "qtbase5-private-dev", "qtdeclarative5-dev",
"qtquickcontrols2-5-dev", "libqt5serialport5-dev", "qtconnectivity5-dev",
"qtpositioning5-dev", "libqt5gamepad5-dev", "libqt5svg5-dev",
)
def build(archive, root, dependency_cache=None, resume=None):
if os.geteuid() == 0:
raise RuntimeError("This build must not run as root")
release = platform.freedesktop_os_release()
if (release.get("ID"), release.get("VERSION_ID"), platform.machine()) != ("ubuntu", "24.04", "x86_64"):
raise RuntimeError("Ubuntu 24.04 amd64 required")
assert hashlib.sha256(archive.read_bytes()).hexdigest() == ARCHIVE_SHA256
group = Path("/sys/fs/cgroup") / Path("/proc/self/cgroup").read_text().strip().split("::", 1)[1].lstrip("/")
limit = (group / "memory.max").read_text().strip()
if limit == "max" or int(limit) > 3 * 1024**3:
raise RuntimeError("Run in a user scope with MemoryMax=3G")
os.umask(0o077)
root.mkdir(mode=0o700, parents=True, exist_ok=False)
root = root.resolve()
report = {"schema": "missioncore.vesc.tool-build/v1", "source_commit": COMMIT,
"source_sha256": ARCHIVE_SHA256, "state": "running", "jobs": [],
"started_at": datetime.now(timezone.utc).isoformat(), "monotonic_started": time.monotonic(),
"hardware_access": False, "system_packages_installed": False, "packages": []}
env = dict(os.environ, LC_ALL="C", DEBIAN_FRONTEND="noninteractive", QT_QPA_PLATFORM="offscreen")
def publish():
(root / "report.json").write_text(json.dumps(report, indent=2) + "\n")
def run(name, args, cwd=None, timeout=600):
job = {"id": name, "state": "running"}; report["jobs"].append(job); publish()
started = time.monotonic()
with (root / (name + ".stdout")).open("wb") as out, (root / (name + ".stderr")).open("wb") as err:
result = subprocess.run(args, cwd=cwd or root, env=env, stdout=out, stderr=err, timeout=timeout)
job.update(state="complete" if result.returncode == 0 else "error", exit_code=result.returncode,
duration_seconds=time.monotonic() - started)
publish()
if result.returncode:
raise RuntimeError("Build step failed: " + name)
return (root / (name + ".stdout")).read_text()
try:
staging = root
if resume is not None:
previous_raw = (resume / "report.json").read_bytes()
previous = json.loads(previous_raw)
if previous.get("source_sha256") != ARCHIVE_SHA256 or previous.get("error") != "Build step failed: compile":
raise RuntimeError("Only this source's failed compile may resume")
staging = resume.resolve()
report["resume_report_sha256"] = hashlib.sha256(previous_raw).hexdigest()
report["resumed_staging"] = str(staging)
downloads = staging / "packages"
if resume is None: downloads.mkdir()
sysroot = staging / "sysroot"
if resume is None: sysroot.mkdir()
if resume is not None:
report["packages"] = previous["packages"]
for item in report["packages"]:
if hashlib.sha256((downloads / item["file"]).read_bytes()).hexdigest() != item["sha256"]:
raise RuntimeError("Resumed dependency changed")
elif dependency_cache is not None:
previous = json.loads((dependency_cache / "report.json").read_text())
if previous.get("source_sha256") != ARCHIVE_SHA256 or not previous.get("packages"):
raise RuntimeError("Unqualified dependency cache")
for item in previous["packages"]:
name = item["file"]
if Path(name).name != name or not name.endswith(".deb"):
raise RuntimeError("Invalid cached package name")
package = dependency_cache / "packages" / name
if hashlib.sha256(package.read_bytes()).hexdigest() != item["sha256"]:
raise RuntimeError("Cached dependency changed")
shutil.copyfile(package, downloads / name)
report["dependency_cache_report_sha256"] = hashlib.sha256((dependency_cache / "report.json").read_bytes()).hexdigest()
else:
# Host lists may refer to superseded security packages. Refresh only
# this job's signed Ubuntu indexes; never update /var/lib/apt or invoke
# the host's update hooks (Timescale and other sources are irrelevant).
aptdir = root / "apt"; aptdir.mkdir()
for name in ("lists", "lists/partial", "archives", "archives/partial"):
(aptdir / name).mkdir(exist_ok=True)
sources = aptdir / "sources.list"
sources.write_text("".join(
"deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg] " + url + " " + suite + " main universe\n"
for url, suite in (("https://archive.ubuntu.com/ubuntu", "noble"),
("https://archive.ubuntu.com/ubuntu", "noble-updates"),
("https://security.ubuntu.com/ubuntu", "noble-security"))))
config = aptdir / "apt.conf"
config.write_text(
'Dir::Etc::Parts "-";\nDir::Etc::main "-";\n'
'Dir::Etc::sourceparts "-";\nDir::Etc::sourcelist "' + str(sources) + '";\n'
'Dir::State::lists "' + str(aptdir / "lists") + '";\n'
'Dir::Cache::archives "' + str(aptdir / "archives") + '";\n'
'Dir::Cache::pkgcache "";\nDir::Cache::srcpkgcache "";\n'
'Acquire::Languages "none";\nDebug::NoLocking "true";\n'
'#clear APT::Update::Post-Invoke;\n#clear APT::Update::Post-Invoke-Success;\n')
env["APT_CONFIG"] = str(config)
apt = ["/usr/bin/apt-get"]
run("private-indexes", [*apt, "update"])
plan = run("dependencies-plan", [*apt, "--simulate", "--no-install-recommends", "--no-remove", "install", *DEPS])
packages = re.findall(r"^Inst (\S+)(?: \[[^\]]+\])? \((\S+)", plan, re.MULTILINE)
if not packages or len(packages) > 150:
raise RuntimeError("Unexpected dependency plan; inspect before changing profile")
for index, (name, version) in enumerate(packages):
# apt-get download verifies the archive against the host's trusted
# repository metadata. No maintainer script or package install runs.
run("download-%03d" % index, [*apt, "download", name + "=" + version], downloads)
for index, package in enumerate(sorted(downloads.glob("*.deb")) if resume is None else []):
report["packages"].append({"file": package.name, "sha256": hashlib.sha256(package.read_bytes()).hexdigest()})
run("extract-%03d" % index, ["/usr/bin/dpkg-deb", "--extract", str(package), str(sysroot)])
source = staging / "source"
if resume is None:
source.mkdir()
with tarfile.open(archive) as stream:
stream.extractall(source, filter="data")
source = source / ("vesc_tool-" + COMMIT)
qtbase = sysroot / "usr"
# APT omits already-installed runtime packages. Complete the private
# development symlinks from declared host libraries, recording provenance.
report["host_libraries"] = []
for name in ("libGL.so.1", "libGLX.so.0", "libGLU.so.1"):
target = qtbase / "lib/x86_64-linux-gnu" / name
host = Path("/usr/lib/x86_64-linux-gnu") / name
if not target.exists() and host.exists():
shutil.copyfile(host, target)
report["host_libraries"].append({"source": str(host.resolve()), "sha256": hashlib.sha256(host.read_bytes()).hexdigest()})
qtarch = qtbase / "lib/x86_64-linux-gnu/qt5"
qtbin = qtbase / "lib/qt5/bin"
qtconfig = "[Paths]\nPrefix=" + str(qtbase) + "\n" + "\n".join(
name + "=" + str(path) for name, path in {
"Headers": qtbase / "include/x86_64-linux-gnu/qt5",
"Libraries": qtbase / "lib/x86_64-linux-gnu", "ArchData": qtarch,
"HostData": qtarch, "Binaries": qtbin, "HostBinaries": qtbin,
"Plugins": qtarch / "plugins", "Qml2Imports": qtarch / "qml",
"Data": qtbase / "share/qt5",
}.items()) + "\n"
(qtbin / "qt.conf").write_text(qtconfig)
env["LD_LIBRARY_PATH"] = str(qtbase / "lib/x86_64-linux-gnu")
env["QT_PLUGIN_PATH"] = str(qtarch / "plugins")
env["PKG_CONFIG_LIBDIR"] = str(qtbase / "lib/x86_64-linux-gnu/pkgconfig")
env["PKG_CONFIG_SYSROOT_DIR"] = str(sysroot)
run("qmake", [str(qtbin / "qmake"), "-config", "release", "CONFIG += release_lin build_original exclude_fw",
"VT_GIT_COMMIT=" + COMMIT[:8], "INCLUDEPATH += " + str(qtbase / "include") + " " + str(qtbase / "include/x86_64-linux-gnu"),
"QMAKE_LIBDIR += " + str(qtbase / "lib/x86_64-linux-gnu")], source)
run("compile", ["/usr/bin/make", "-j2"], source, timeout=1800)
binary = source / "build/lin/vesc_tool_7.00"
result = run("version", [str(binary), "--version"], source, timeout=30)
report.update(state="complete", binary=str(binary), binary_sha256=hashlib.sha256(binary.read_bytes()).hexdigest(),
version_output=result, runtime_installed=False, hardware_qualified=False)
except Exception as error:
report.update(state="error", error=str(error))
raise
finally:
report["duration_seconds"] = time.monotonic() - report["monotonic_started"]
report["finished_at"] = datetime.now(timezone.utc).isoformat()
publish()
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--dependencies", type=Path)
parser.add_argument("--resume", type=Path)
args = parser.parse_args()
build(args.source.resolve(), args.output, args.dependencies, args.resume)