143 lines
5.2 KiB
Python
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())
|