Files

151 lines
5.8 KiB
Python
Raw Permalink Normal View History

"""Build and verify a credential-free Silver Pose V1 release directory."""
import argparse
import hashlib
import json
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Iterable
class ReleaseError(ValueError):
"""Raised when a release package cannot be safely created or verified."""
_EXCLUDED_NAMES = {"config.local.json", "artifacts", "__pycache__", ".pytest_cache", "models"}
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with Path(path).open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def build_release_package(
project_root: Path, model_path: Path, expected_sha256: str, output_dir: Path
) -> Dict:
"""Copy the V1 runtime and a locked model without local customer artifacts."""
root = Path(project_root).resolve()
source_model = Path(model_path).resolve()
output = Path(output_dir).resolve()
expected = _validate_model(source_model, expected_sha256)
if output.exists():
raise ReleaseError("output directory already exists: {0}".format(output))
output.mkdir(parents=True)
_copy_runtime_tree(root / "v1", output / "v1")
_copy_required_file(root / "README.md", output / "README.md")
_copy_required_file(root / "init.ps1", output / "init.ps1")
_copy_required_file(root / "docs" / "demo-runbook.md", output / "docs" / "demo-runbook.md")
destination_model = output / "v1" / "models" / "best.pt"
destination_model.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(source_model), str(destination_model))
_write_release_config(root / "v1" / "config.example.json", output / "v1", expected)
manifest = _manifest(output, expected)
(output / "release-manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8"
)
return manifest
def verify_release_package(package_dir: Path) -> Dict:
package = Path(package_dir).resolve()
manifest_path = package / "release-manifest.json"
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise ReleaseError("cannot read release manifest: {0}".format(exc))
for relative_path, expected in manifest.get("files", {}).items():
actual_path = package / relative_path
if not actual_path.is_file() or sha256_file(actual_path) != expected:
raise ReleaseError("release file SHA-256 does not match: {0}".format(relative_path))
model = package / "v1" / "models" / "best.pt"
if not model.is_file() or sha256_file(model) != manifest.get("model_sha256"):
raise ReleaseError("model SHA-256 does not match release manifest")
return manifest
def _validate_model(model_path: Path, expected_sha256: str) -> str:
expected = str(expected_sha256).lower()
if len(expected) != 64 or any(char not in "0123456789abcdef" for char in expected):
raise ReleaseError("expected model SHA-256 must be 64 hexadecimal characters")
if not model_path.is_file():
raise ReleaseError("model file does not exist: {0}".format(model_path))
if sha256_file(model_path) != expected:
raise ReleaseError("model SHA-256 does not match expected value")
return expected
def _copy_runtime_tree(source: Path, destination: Path) -> None:
if not source.is_dir():
raise ReleaseError("V1 source directory does not exist: {0}".format(source))
shutil.copytree(
str(source),
str(destination),
ignore=shutil.ignore_patterns(*_EXCLUDED_NAMES),
)
def _copy_required_file(source: Path, destination: Path) -> None:
if not source.is_file():
raise ReleaseError("required release file does not exist: {0}".format(source))
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(source), str(destination))
def _write_release_config(source: Path, destination_dir: Path, model_sha256: str) -> None:
raw = json.loads(source.read_text(encoding="utf-8"))
raw["model"]["sha256"] = model_sha256
(destination_dir / "config.release.example.json").write_text(
json.dumps(raw, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
def _manifest(package: Path, model_sha256: str) -> Dict:
files = {}
for path in _files(package):
files[path.relative_to(package).as_posix()] = sha256_file(path)
return {
"format": "silver-pose-v1-release-1",
"created_at_utc": datetime.now(timezone.utc).isoformat(),
"model_sha256": model_sha256,
"files": files,
}
def _files(root: Path) -> Iterable[Path]:
for path in sorted(root.rglob("*")):
if path.is_file() and path.name != "release-manifest.json":
yield path
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description="Build or verify a Silver Pose V1 release")
commands = parser.add_subparsers(dest="command", required=True)
build = commands.add_parser("build")
build.add_argument("--project-root", default=".")
build.add_argument("--model-path", required=True)
build.add_argument("--model-sha256", required=True)
build.add_argument("--output", required=True)
verify = commands.add_parser("verify")
verify.add_argument("--package", required=True)
args = parser.parse_args(argv)
if args.command == "build":
manifest = build_release_package(
Path(args.project_root), Path(args.model_path), args.model_sha256, Path(args.output)
)
print("release package built: {0}".format(args.output))
print("model SHA-256: {0}".format(manifest["model_sha256"]))
return 0
verify_release_package(Path(args.package))
print("release package verified: {0}".format(args.package))
return 0
if __name__ == "__main__":
raise SystemExit(main())