#!/usr/bin/env python3 """Build a deterministic Debian package on macOS/Linux from reviewed artifacts. No install operation, sudo, container, package-manager mutation or network I/O. """ import argparse import gzip import hashlib import io import json from pathlib import Path import tarfile ROOT = Path(__file__).resolve().parents[1] VERSION = "0.6.11" BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af" def desktop_icon(brand): """Give desktop loaders a square canvas without distorting the brand mark. The canonical SVG remains an unchanged nested document. Its default xMidYMid meet preserves the mark's aspect ratio inside this square viewport. Explicit intrinsic dimensions also keep GTK's pixbuf square. """ if hashlib.sha256(brand).hexdigest() != BRAND_SHA256: raise ValueError("Brand mark differs from the admitted Design Guideline asset") return (b'\n' + brand + b'\n') def tarball(files): stream = io.BytesIO() with tarfile.open(fileobj=stream, mode="w", format=tarfile.GNU_FORMAT) as archive: directories = {str(parent) for name, _, _ in files for parent in Path(name).parents if str(parent) != "."} for name in sorted(directories): item = tarfile.TarInfo(name + "/") item.type, item.mode = tarfile.DIRTYPE, 0o755 item.uname = item.gname = "root" archive.addfile(item) for name, data, mode in sorted(files): item = tarfile.TarInfo(name) item.size, item.mode, item.uid, item.gid = len(data), mode, 0, 0 item.uname = item.gname = "root" archive.addfile(item, io.BytesIO(data)) return gzip.compress(stream.getvalue(), mtime=0) def ar_member(name, data): header = f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{'100644':<8}{len(data):<10}`\n".encode() assert len(header) == 60 return header + data + (b"\n" if len(data) % 2 else b"") def build(binary, destination): payload = binary.read_bytes() if payload[:4] != b"\x7fELF" or payload[4:6] != b"\x02\x01" or payload[18:20] != b"\x3e\x00": raise ValueError("Expected a Linux amd64 ELF binary") p = ROOT / "packaging" control = f"""Package: mission-core-node Version: {VERSION} Architecture: amd64 Maintainer: NODE.DC local build Section: admin Priority: optional Depends: adduser, systemd, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme Description: Mission Core onboard computer configuration Local graphical setup, host inventory, SSH access and persistent node identity. """.encode() controls = [("control", control, 0o644)] controls += [(name, (p / name).read_bytes(), 0o755) for name in ["preinst", "postinst", "prerm", "postrm"]] files = [("usr/lib/mission-core-node/node-agent", payload, 0o755)] brand = (ROOT.parents[2] / "NODEDC_DESIGN_GUIDELINE/apps/catalog/public/nodedc-mark.svg").read_bytes() files.append(("usr/share/icons/hicolor/scalable/apps/org.nodedc.MissionCoreNode.svg", desktop_icon(brand), 0o644)) for source, path, mode in [ ("launcher.py", "usr/bin/mission-core-node", 0o755), ("authorize", "usr/lib/mission-core-node/authorize", 0o755), ("mission-core-node.desktop", "usr/share/applications/org.nodedc.MissionCoreNode.desktop", 0o644), ("mission-core-node.service", "usr/lib/systemd/system/mission-core-node.service", 0o644), ("org.nodedc.mission-core-node.policy", "usr/share/polkit-1/actions/org.nodedc.mission-core-node.policy", 0o644), ("60-mission-core-node.conf", "usr/share/mission-core-node/60-mission-core-node.conf", 0o644), ("network_helper.py", "usr/lib/mission-core-node/network_helper.py", 0o644), ("install-tailscale", "usr/lib/mission-core-node/install-tailscale", 0o755), ("connect-tailscale", "usr/lib/mission-core-node/connect-tailscale", 0o755), ("tailscale-release.json", "usr/share/mission-core-node/tailscale-release.json", 0o644), ("configure-system", "usr/lib/mission-core-node/configure-system", 0o755), ("environment_helper.py", "usr/lib/mission-core-node/environment_helper.py", 0o644), ("mission-core-node-environment.service", "usr/lib/systemd/system/mission-core-node-environment.service", 0o644), ("60-environment.conf", "usr/share/mission-core-node/60-environment.conf", 0o644), ]: files.append((path, (p / source).read_bytes(), mode)) files.append(("usr/share/mission-core-node/environment-profile.json", (ROOT / "internal/node/environment-profile.json").read_bytes(), 0o644)) for name in ("realsense_prepare.py", "realsense_iio_access.py"): files.append(("usr/lib/mission-core-node/" + name, (p / name).read_bytes(), 0o644)) for name in ("mission-core-realsense.service", "mission-core-node-realsense-prepare.service"): files.append(("usr/lib/systemd/system/" + name, (p / name).read_bytes(), 0o644)) files.append(("usr/share/polkit-1/rules.d/50-mission-core-device-prepare.rules", (p / "50-mission-core-device-prepare.rules").read_bytes(), 0o644)) files.append(("usr/share/mission-core-node/realsense/70-mission-core-realsense.rules", (p / "70-mission-core-realsense.rules").read_bytes(), 0o644)) bundle = json.loads((p / "realsense-bundle.json").read_text()) files.append(("usr/share/mission-core-node/realsense/bundle.json", (p / "realsense-bundle.json").read_bytes(), 0o644)) for item in bundle["wheels"]: data = (ROOT / "build/realsense-wheels" / item["name"]).read_bytes() if hashlib.sha256(data).hexdigest() != item["sha256"]: raise ValueError("Driver bundle hash mismatch") files.append(("usr/share/mission-core-node/realsense/" + item["name"], data, 0o644)) for path in (ROOT / "sensors").glob("*.py"): files.append(("usr/lib/mission-core-node/sensors/" + path.name, path.read_bytes(), 0o644)) sdk = ROOT.parents[1] / "packages/plugin-sdk/python/missioncore_plugin_sdk" for path in sdk.rglob("*.py"): files.append(("usr/lib/mission-core-node/sdk/missioncore_plugin_sdk/" + str(path.relative_to(sdk)), path.read_bytes(), 0o644)) if (ROOT / "build/provenance.json").exists(): files.append(("usr/share/doc/mission-core-node/provenance.json", (ROOT / "build/provenance.json").read_bytes(), 0o644)) archive = b"!\n" + ar_member("debian-binary", b"2.0\n") + ar_member("control.tar.gz", tarball(controls)) + ar_member("data.tar.gz", tarball(files)) destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(archive) digest = hashlib.sha256(archive).hexdigest() destination.with_suffix(destination.suffix + ".sha256").write_text(f"{digest} {destination.name}\n") print(json.dumps({"file": str(destination), "bytes": len(archive), "sha256": digest})) if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--binary", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) args = parser.parse_args() build(args.binary, args.output)