#!/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.3.1"
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')
def tarball(files):
stream = io.BytesIO()
with tarfile.open(fileobj=stream, mode="w", format=tarfile.USTAR_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, openssh-server, 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.
Ubuntu 24.04 LTS Desktop amd64 qualification candidate.
""".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),
]:
files.append((path, (p / source).read_bytes(), mode))
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)