Add signed runtime distribution pipeline
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble a deterministic, network-free GovOPlaN OCI build context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import configparser
|
||||
from email.parser import BytesParser
|
||||
from email.policy import compat32
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import zipfile
|
||||
|
||||
|
||||
NORMALIZED_PACKAGE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+!-]{0,127}$")
|
||||
MAX_WHEELS = 512
|
||||
MAX_WHEEL_BYTES = 512 * 1024 * 1024
|
||||
MAX_WEB_FILES = 100_000
|
||||
MAX_WEB_BYTES = 2 * 1024 * 1024 * 1024
|
||||
|
||||
|
||||
class ContextError(ValueError):
|
||||
"""The release inputs cannot form an immutable runtime context."""
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--wheelhouse", type=Path, required=True)
|
||||
parser.add_argument("--web-dist", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--required-module", action="append", default=[])
|
||||
parser.add_argument(
|
||||
"--source-date-epoch",
|
||||
type=int,
|
||||
default=int(os.environ.get("SOURCE_DATE_EPOCH", "0") or 0),
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def prepare_context(
|
||||
*,
|
||||
wheelhouse: Path,
|
||||
web_dist: Path,
|
||||
output: Path,
|
||||
required_modules: tuple[str, ...] = (),
|
||||
source_date_epoch: int = 0,
|
||||
) -> dict[str, object]:
|
||||
source_wheels = _regular_files(wheelhouse, suffix=".whl", maximum=MAX_WHEELS)
|
||||
if not source_wheels:
|
||||
raise ContextError("wheelhouse contains no wheel artifacts")
|
||||
if output.exists() and any(output.iterdir()):
|
||||
raise ContextError("output directory must be absent or empty")
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
target_wheels = output / "wheelhouse"
|
||||
target_web = output / "web-dist"
|
||||
target_wheels.mkdir(mode=0o755)
|
||||
|
||||
packages: list[dict[str, object]] = []
|
||||
govoplan_wheel_rows: list[dict[str, object]] = []
|
||||
roots: list[tuple[str, str]] = []
|
||||
module_ids: set[str] = set()
|
||||
seen_packages: set[str] = set()
|
||||
wheel_rows: list[dict[str, object]] = []
|
||||
for source in source_wheels:
|
||||
if source.stat().st_size > MAX_WHEEL_BYTES:
|
||||
raise ContextError(f"wheel exceeds size limit: {source.name}")
|
||||
identity = inspect_wheel(source)
|
||||
package_name = str(identity["package"])
|
||||
if package_name in seen_packages:
|
||||
raise ContextError(f"duplicate wheel distribution: {package_name}")
|
||||
seen_packages.add(package_name)
|
||||
target = target_wheels / source.name
|
||||
_copy_regular(source, target, source_date_epoch=source_date_epoch)
|
||||
row = {
|
||||
"filename": source.name,
|
||||
"sha256": _sha256_file(target),
|
||||
"size": target.stat().st_size,
|
||||
}
|
||||
wheel_rows.append(row)
|
||||
if package_name.startswith("govoplan-"):
|
||||
package_modules = tuple(str(item) for item in identity["module_ids"])
|
||||
module_ids.update(package_modules)
|
||||
package = {
|
||||
**row,
|
||||
"package": package_name,
|
||||
"version": identity["version"],
|
||||
"module_ids": list(package_modules),
|
||||
}
|
||||
packages.append(package)
|
||||
govoplan_wheel_rows.append(row)
|
||||
root = (
|
||||
f"{package_name}[server]"
|
||||
if package_name == "govoplan-core"
|
||||
else package_name
|
||||
)
|
||||
roots.append((root, str(identity["version"])))
|
||||
|
||||
if not any(package["package"] == "govoplan-core" for package in packages):
|
||||
raise ContextError("wheelhouse does not contain govoplan-core")
|
||||
missing_modules = sorted(set(required_modules) - module_ids)
|
||||
if missing_modules:
|
||||
raise ContextError(
|
||||
"runtime composition is missing required modules: "
|
||||
+ ", ".join(missing_modules)
|
||||
)
|
||||
requirements = "".join(
|
||||
f"{package}=={version}\n" for package, version in sorted(roots)
|
||||
)
|
||||
_write_regular(
|
||||
output / "requirements-runtime.txt",
|
||||
requirements.encode("utf-8"),
|
||||
source_date_epoch=source_date_epoch,
|
||||
)
|
||||
|
||||
web_rows = _copy_web_tree(
|
||||
web_dist,
|
||||
target_web,
|
||||
source_date_epoch=source_date_epoch,
|
||||
)
|
||||
composition: dict[str, object] = {
|
||||
"schema_version": "1",
|
||||
"python": {
|
||||
"packages": sorted(packages, key=lambda item: str(item["package"])),
|
||||
"module_ids": sorted(module_ids),
|
||||
"wheelhouse_sha256": _rows_digest(govoplan_wheel_rows),
|
||||
"wheel_count": len(govoplan_wheel_rows),
|
||||
},
|
||||
"web": {
|
||||
"sha256": _rows_digest(web_rows),
|
||||
"file_count": len(web_rows),
|
||||
},
|
||||
}
|
||||
encoded = (json.dumps(composition, indent=2, sort_keys=True) + "\n").encode(
|
||||
"utf-8"
|
||||
)
|
||||
_write_regular(
|
||||
output / "composition.json",
|
||||
encoded,
|
||||
source_date_epoch=source_date_epoch,
|
||||
)
|
||||
_write_regular(
|
||||
target_web / ".well-known" / "govoplan-composition.json",
|
||||
encoded,
|
||||
source_date_epoch=source_date_epoch,
|
||||
)
|
||||
_copy_regular(
|
||||
Path(__file__).resolve().parent / "runtime" / "nginx.conf",
|
||||
output / "nginx.conf",
|
||||
source_date_epoch=source_date_epoch,
|
||||
)
|
||||
return composition
|
||||
|
||||
|
||||
def inspect_wheel(path: Path) -> dict[str, object]:
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(path, flags)
|
||||
except OSError as exc:
|
||||
raise ContextError(f"wheel cannot be opened safely: {path.name}") from exc
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(opened.st_mode):
|
||||
raise ContextError(f"wheel is not a regular file: {path.name}")
|
||||
with os.fdopen(os.dup(descriptor), "rb") as handle:
|
||||
with zipfile.ZipFile(handle) as archive:
|
||||
metadata = [
|
||||
member
|
||||
for member in archive.infolist()
|
||||
if PurePosixPath(member.filename).name == "METADATA"
|
||||
and PurePosixPath(member.filename).parent.name.endswith(
|
||||
".dist-info"
|
||||
)
|
||||
]
|
||||
if len(metadata) != 1:
|
||||
raise ContextError(
|
||||
f"wheel must contain one METADATA file: {path.name}"
|
||||
)
|
||||
parsed = BytesParser(policy=compat32).parsebytes(
|
||||
archive.read(metadata[0])
|
||||
)
|
||||
package = _normalize_package(str(parsed.get("Name") or ""))
|
||||
version = str(parsed.get("Version") or "").strip()
|
||||
if VERSION.fullmatch(version) is None:
|
||||
raise ContextError(f"wheel has invalid version: {path.name}")
|
||||
entry_points_name = (
|
||||
PurePosixPath(metadata[0].filename).parent / "entry_points.txt"
|
||||
).as_posix()
|
||||
module_ids: tuple[str, ...] = ()
|
||||
if entry_points_name in archive.namelist():
|
||||
module_ids = _module_entry_points(
|
||||
archive.read(entry_points_name).decode("utf-8")
|
||||
)
|
||||
final = os.fstat(descriptor)
|
||||
if (opened.st_dev, opened.st_ino, opened.st_size, opened.st_mtime_ns) != (
|
||||
final.st_dev,
|
||||
final.st_ino,
|
||||
final.st_size,
|
||||
final.st_mtime_ns,
|
||||
):
|
||||
raise ContextError(f"wheel changed while inspected: {path.name}")
|
||||
except (OSError, RuntimeError, zipfile.BadZipFile) as exc:
|
||||
if isinstance(exc, ContextError):
|
||||
raise
|
||||
raise ContextError(f"wheel is not a readable archive: {path.name}") from exc
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
return {"package": package, "version": version, "module_ids": module_ids}
|
||||
|
||||
|
||||
def _module_entry_points(value: str) -> tuple[str, ...]:
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=True)
|
||||
try:
|
||||
parser.read_string(value)
|
||||
except configparser.Error as exc:
|
||||
raise ContextError("wheel entry_points.txt is malformed") from exc
|
||||
if not parser.has_section("govoplan.modules"):
|
||||
return ()
|
||||
values = tuple(sorted(parser.options("govoplan.modules")))
|
||||
for item in values:
|
||||
if re.fullmatch(r"[a-z][a-z0-9_]{1,63}", item) is None:
|
||||
raise ContextError(f"wheel has invalid module entry point: {item!r}")
|
||||
return values
|
||||
|
||||
|
||||
def _normalize_package(value: str) -> str:
|
||||
normalized = re.sub(r"[-_.]+", "-", value.strip().lower())
|
||||
if NORMALIZED_PACKAGE.fullmatch(normalized) is None:
|
||||
raise ContextError("wheel has invalid package name")
|
||||
return normalized
|
||||
|
||||
|
||||
def _regular_files(root: Path, *, suffix: str, maximum: int) -> list[Path]:
|
||||
if root.is_symlink() or not root.is_dir():
|
||||
raise ContextError(f"input directory is not a real directory: {root}")
|
||||
values = sorted(path for path in root.iterdir() if path.name.endswith(suffix))
|
||||
if len(values) > maximum:
|
||||
raise ContextError(f"input directory exceeds {maximum} files")
|
||||
for path in values:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ContextError(f"input artifact is not a regular file: {path.name}")
|
||||
return values
|
||||
|
||||
|
||||
def _copy_web_tree(
|
||||
source: Path,
|
||||
target: Path,
|
||||
*,
|
||||
source_date_epoch: int,
|
||||
) -> list[dict[str, object]]:
|
||||
if source.is_symlink() or not source.is_dir():
|
||||
raise ContextError("WebUI dist must be a real directory")
|
||||
rows: list[dict[str, object]] = []
|
||||
total = 0
|
||||
for path in sorted(source.rglob("*")):
|
||||
relative = path.relative_to(source)
|
||||
if path.is_symlink():
|
||||
raise ContextError(f"WebUI dist contains a symlink: {relative}")
|
||||
if path.is_dir():
|
||||
continue
|
||||
if not path.is_file():
|
||||
raise ContextError(f"WebUI dist contains a special file: {relative}")
|
||||
if len(rows) >= MAX_WEB_FILES:
|
||||
raise ContextError("WebUI dist exceeds its file-count limit")
|
||||
total += path.stat().st_size
|
||||
if total > MAX_WEB_BYTES:
|
||||
raise ContextError("WebUI dist exceeds its total-size limit")
|
||||
destination = target / relative
|
||||
_copy_regular(path, destination, source_date_epoch=source_date_epoch)
|
||||
rows.append(
|
||||
{
|
||||
"path": relative.as_posix(),
|
||||
"sha256": _sha256_file(destination),
|
||||
"size": destination.stat().st_size,
|
||||
}
|
||||
)
|
||||
if not rows:
|
||||
raise ContextError("WebUI dist contains no files")
|
||||
return rows
|
||||
|
||||
|
||||
def _copy_regular(source: Path, target: Path, *, source_date_epoch: int) -> None:
|
||||
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
with source.open("rb") as source_handle, target.open("xb") as target_handle:
|
||||
shutil.copyfileobj(source_handle, target_handle)
|
||||
target_handle.flush()
|
||||
os.fsync(target_handle.fileno())
|
||||
target.chmod(0o644)
|
||||
os.utime(target, (source_date_epoch, source_date_epoch))
|
||||
|
||||
|
||||
def _write_regular(path: Path, value: bytes, *, source_date_epoch: int) -> None:
|
||||
path.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
path.write_bytes(value)
|
||||
path.chmod(0o644)
|
||||
os.utime(path, (source_date_epoch, source_date_epoch))
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _rows_digest(rows: list[dict[str, object]]) -> str:
|
||||
encoded = json.dumps(rows, separators=(",", ":"), sort_keys=True).encode(
|
||||
"utf-8"
|
||||
)
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
try:
|
||||
composition = prepare_context(
|
||||
wheelhouse=args.wheelhouse.expanduser().resolve(),
|
||||
web_dist=args.web_dist.expanduser().resolve(),
|
||||
output=args.output.expanduser().resolve(),
|
||||
required_modules=tuple(args.required_module),
|
||||
source_date_epoch=args.source_date_epoch,
|
||||
)
|
||||
except (ContextError, OSError) as exc:
|
||||
print(f"error: {exc}", file=os.sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(composition, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user