189 lines
11 KiB
Python
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)
|