Files
bastion-agent/bastion_agent/build.py
T
2026-09-26 18:31:40 +00:00

143 lines
5.2 KiB
Python

"""Build the bastion agent distributable: one reproducible zipapp.
python3 -m bastion_agent.build [--out dist]
Produces dist/bastion-agent-<version>.pyz and dist/bastion-agent-<version>.pyz.sha256.
The .pyz runs on any Python >= 3.8 on Mac, Windows or Linux:
python3 bastion-agent-0.1.0.pyz doctor
python3 bastion-agent-0.1.0.pyz sweep manifest.yaml
Inside: the `bastion_agent` package (no tests), the `pii` package (no tests,
no Dockerfile), and a vendored pure-Python PyYAML (MIT; the one import
the manifest loader needs that is not stdlib — the C accelerator is left
out on purpose so the same file runs everywhere). age and openssl stay
what they are: single external binaries `doctor` checks for. The repo's
`LICENSE` (GNU GPL v3 or later) is archived at the top level, so the
license travels with the program as GPL-3 sections 4 and 5 require.
Reproducible: fixed timestamps, sorted members, no bytecode, and the
vendored PyYAML pinned to one release (PYYAML_VERSION). Two builds of the
same tree are byte-identical on any machine, so the .sha256 means something
and anyone can rebuild a release and compare.
A native per-OS binary (PyInstaller/Nuitka) needs a build machine per OS
and is not produced here; this is the artifact until then.
"""
import argparse
import hashlib
import io
import os
import sys
import zipfile
HERE = os.path.dirname(os.path.abspath(__file__))
TOOLS = os.path.dirname(HERE)
FIXED_TIME = (2026, 1, 1, 0, 0, 0)
SHEBANG = b"#!/usr/bin/env python3\n"
MAIN_SHIM = '''"""bastion agent zipapp entry point."""
import sys
from bastion_agent.__main__ import main
sys.exit(main())
'''
YAML_NOTICE = """PyYAML (vendored, pure-Python modules only)
License: MIT — Copyright (c) 2017-2021 Ingy döt Net, Copyright (c) 2006-2016 Kirill Simonov
https://github.com/yaml/pyyaml — the C accelerator (_yaml) is deliberately not included.
"""
def version():
with open(os.path.join(HERE, "VERSION"), encoding="utf-8") as fh:
return fh.read().strip()
def _package_files(src_dir, exclude_prefixes=("test_",), exclude_names=()):
for name in sorted(os.listdir(src_dir)):
if not name.endswith(".py") or name.startswith(exclude_prefixes) or name in exclude_names:
continue
with open(os.path.join(src_dir, name), "rb") as fh:
yield name, fh.read()
# The PyYAML release that is vendored. Its source is copied in from whatever
# `import yaml` finds, so without a pin two machines with different PyYAML
# builds would make different .pyz files from the same tree. Move it on
# purpose, in its own commit.
PYYAML_VERSION = "6.0.3"
def _yaml_files():
import yaml
if getattr(yaml, "__version__", "") != PYYAML_VERSION:
raise SystemExit(
"build needs PyYAML %s to vendor (found %s at %s): pip install pyyaml==%s"
% (PYYAML_VERSION, getattr(yaml, "__version__", "?"), yaml.__file__, PYYAML_VERSION))
d = os.path.dirname(yaml.__file__)
for name in sorted(os.listdir(d)):
if name.endswith(".py"):
with open(os.path.join(d, name), "rb") as fh:
yield name, fh.read()
def members():
"""(arcname, bytes) for everything in the zipapp, deterministic order."""
out = [("__main__.py", MAIN_SHIM.encode("utf-8"))]
# build.py and verify_release.py are tools for making and checking a
# release; neither is part of the program a bastion runs.
for name, data in _package_files(HERE, exclude_names=("build.py", "verify_release.py")):
out.append(("bastion_agent/" + name, data))
with open(os.path.join(HERE, "VERSION"), "rb") as fh:
out.append(("bastion_agent/VERSION", fh.read()))
for name, data in _package_files(os.path.join(TOOLS, "pii")):
out.append(("pii/" + name, data))
for name, data in _yaml_files():
out.append(("yaml/" + name, data))
out.append(("yaml/LICENSE-NOTICE.txt", YAML_NOTICE.encode("utf-8")))
with open(os.path.join(TOOLS, "LICENSE"), "rb") as fh:
out.append(("LICENSE", fh.read()))
return sorted(out, key=lambda m: m[0])
def build_bytes():
buf = io.BytesIO()
buf.write(SHEBANG)
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for arcname, data in members():
info = zipfile.ZipInfo(arcname, date_time=FIXED_TIME)
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o644 << 16
zf.writestr(info, data)
return buf.getvalue()
def build(out_dir="dist"):
os.makedirs(out_dir, exist_ok=True)
data = build_bytes()
ver = version()
path = os.path.join(out_dir, "bastion-agent-%s.pyz" % ver)
with open(path, "wb") as fh:
fh.write(data)
try:
os.chmod(path, 0o755)
except OSError:
pass
digest = hashlib.sha256(data).hexdigest()
with open(path + ".sha256", "w", encoding="utf-8") as fh:
fh.write("%s %s\n" % (digest, os.path.basename(path)))
return path, digest, len(data)
def main(argv=None):
ap = argparse.ArgumentParser(prog="bastion_agent.build")
ap.add_argument("--out", default="dist")
args = ap.parse_args(argv)
path, digest, size = build(args.out)
print("%s %d bytes sha256 %s" % (path, size, digest))
return 0
if __name__ == "__main__":
sys.exit(main())