Files

113 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""Produce an offline release archive from an independently vetted ARM64 staging tree.
This tool does not build/download a runtime, model, native libraries, or licenses.
"""
import argparse
from datetime import datetime
import hashlib
import io
import json
import os
from pathlib import Path
import re
import sys
import tarfile
import tempfile
ARCH = "linux-aarch64"
ALLOWED = {"bin", "lib", "assets", "python", "scripts", "runtime", "model", "fonts", "licenses"}
REQUIRED = ("bin/frameyap", "bin/install.sh", "runtime/bin/python3", "python/frameyap/worker.py",
"assets/actions.json", "fonts/font.ttf", "licenses/THIRD_PARTY_NOTICES.txt")
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--stage", type=Path, required=True, help="vetted standalone payload directory")
p.add_argument("--output", type=Path, required=True, help="existing output directory")
p.add_argument("--version", required=True)
p.add_argument("--arch", choices=[ARCH], required=True)
p.add_argument("--model-revision", required=True, help="exact vetted model revision identifier")
p.add_argument("--external-runtime", action="store_true", help="omit ASR runtime; user must supply an independently authorized Python environment")
args = p.parse_args(argv)
if not re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.[1-9][0-9]{11}", args.version):
p.error("--version must be numeric MAJOR.MINOR.YYYYMMDDHHMM (no leading zeros, v prefix or git suffix)")
try:
datetime.strptime(args.version.rsplit(".", 1)[1], "%Y%m%d%H%M")
except ValueError:
p.error("--version contains an invalid UTC date/time")
if not args.model_revision.strip():
p.error("model revision must be nonempty")
stage = args.stage.resolve(strict=True)
if not stage.is_dir() or args.stage.is_symlink():
p.error("stage must be a real directory")
if not args.output.is_dir() or args.output.is_symlink():
p.error("output must be an existing real directory")
if any(item.name not in ALLOWED for item in stage.iterdir()):
p.error("stage has unknown root entries or preexisting release.json")
for name in REQUIRED:
if args.external_runtime and name == "runtime/bin/python3":
continue
if not (stage / name).is_file() or (stage / name).is_symlink():
p.error(f"missing file: {name}")
if args.external_runtime and (stage / "runtime").exists():
p.error("external-runtime package must not contain a runtime directory")
for name in (("bin/frameyap", "bin/install.sh") if args.external_runtime else ("bin/frameyap", "bin/install.sh", "runtime/bin/python3")):
if not os.access(stage / name, os.X_OK):
p.error(f"not executable: {name}")
for name in ("lib", "fonts", "licenses"):
if not (stage / name).is_dir() or not any((stage / name).iterdir()):
p.error(f"missing directory or empty: {name}")
if not (stage / "licenses/THIRD_PARTY_NOTICES.txt").read_text().strip():
p.error("third-party notices must not be empty; review redistribution licenses")
if (stage / "model").exists() and not any((stage / "model").rglob("*")):
p.error("model directory is empty")
entries = sorted(stage.rglob("*"), key=lambda item: item.relative_to(stage).as_posix())
if len(entries) > 49999:
p.error("too many payload members")
total = 0
for entry in entries:
name = entry.relative_to(stage).as_posix()
if (len(name) > 1024 or len(name.split("/")) > 32 or "\\" in name
or name.endswith("/") or any(part in ("", ".", "..") for part in name.split("/"))):
p.error(f"unsupported archive member path: {name}")
if entry.is_symlink() or not (entry.is_dir() or entry.is_file()):
p.error(f"links and special files forbidden (copy vetted files into stage): {entry}")
if entry.is_file():
total += entry.stat().st_size
if total > 12 * 1024**3:
p.error("payload exceeds 12 GiB uncompressed limit")
name = f"frameyap-{args.version}-{ARCH}.tar.gz"
target = args.output / name
sidecar = args.output / (name + ".sha256")
if target.exists() or sidecar.exists() or target.is_symlink() or sidecar.is_symlink():
p.error("release output exists; refusing to replace it")
meta = {"schema": 1, "version": args.version, "arch": ARCH,
"runtime": "external-authorized-python" if args.external_runtime else "bundled-cpu-python", "model_revision": args.model_revision,
"includes_model": (stage / "model").is_dir()}
fd, temp = tempfile.mkstemp(prefix=".frameyap-package-", dir=args.output)
os.close(fd)
try:
with tarfile.open(temp, "w:gz", format=tarfile.PAX_FORMAT, compresslevel=1) as tar:
payload = (json.dumps(meta, sort_keys=True, indent=2) + "\n").encode()
info = tarfile.TarInfo("release.json")
info.size = len(payload)
info.mode = 0o600
tar.addfile(info, io.BytesIO(payload))
for entry in entries:
tar.add(entry, arcname=entry.relative_to(stage).as_posix(), recursive=False)
digest = hashlib.sha256()
with open(temp, "rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
os.replace(temp, target)
sidecar.write_text(f"{digest.hexdigest()} {name}\n")
print(f"Wrote {target} and {sidecar}")
finally:
if os.path.exists(temp):
os.unlink(temp)
if __name__ == "__main__":
main()