#!/usr/bin/env python3 """build-config-bundle.py — build the agent's CONFIG BUNDLE (R-840, `11` §5.4.2). Usage: python3 scripts/build-config-bundle.py (prints the bundle's sha256) The bundle is every root-owned file the installer's step 5 writes for the agent (sudoers, wrappers, units), as ONE JSON file published beside the binary (felhom-agent//felhom-config-bundle.json). A box takes it by a signed `agent_config_update` job; a new box takes the SAME file from the installer. The list of files is NOT kept here: it is `BUNDLE_FILES` in configs/felhom-os-apply, the root wrapper that installs it — one table, so the builder cannot put in a path the wrapper would refuse, nor leave out one it expects. Reproducible by construction: no timestamps, sorted keys, the table's order. The same source at the same version gives the same sha256 every time (pinned by configs/test_felhom_config_bundle.py). """ import base64 import hashlib import importlib.machinery import importlib.util import json import pathlib import re import sys REPO = pathlib.Path(__file__).resolve().parent.parent CONFIGS = REPO / "configs" def load_wrapper(configs=CONFIGS): loader = importlib.machinery.SourceFileLoader("osapply_for_bundle", str(configs / "felhom-os-apply")) spec = importlib.util.spec_from_loader("osapply_for_bundle", loader) mod = importlib.util.module_from_spec(spec) loader.exec_module(mod) return mod def build(version, configs=CONFIGS): if not re.match(r"^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$", version): raise SystemExit(f"build-config-bundle: version {version!r} is not semver") w = load_wrapper(configs) files = [] for dest, src, mode, check, policy in w.BUNDLE_FILES: data = (configs / src).read_bytes() files.append({"path": dest, "source": f"configs/{src}", "mode": oct(mode), "check": check, "policy": policy, "sha256": hashlib.sha256(data).hexdigest(), "content_b64": base64.b64encode(data).decode()}) body = {"format": w.BUNDLE_FORMAT, "agent_version": version, "files": files} return (json.dumps(body, indent=1, sort_keys=True) + "\n").encode() def main(argv): if len(argv) != 3: print(__doc__, file=sys.stderr) return 2 data = build(argv[1]) pathlib.Path(argv[2]).write_bytes(data) print(hashlib.sha256(data).hexdigest()) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))