181 lines
6.5 KiB
Python
181 lines
6.5 KiB
Python
"""Publish command implementation (contracts/cli.md publish, contracts/registry-layout.md).
|
|
|
|
Validates a bundle against the frozen schemas (schemas/*.json), packages
|
|
payload/+recipe.yaml+solution.json into a release tarball, and runs the
|
|
publish protocol against the registry: branch -> commit -> release -> PR.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
import tarfile
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from jsonschema import Draft202012Validator
|
|
|
|
from m2_market.registry import RegistryClient
|
|
|
|
|
|
class PublishValidationError(Exception):
|
|
"""Raised when the bundle fails schema validation (contracts/cli.md exit 5)."""
|
|
|
|
def __init__(self, errors: list[str]):
|
|
super().__init__("; ".join(errors))
|
|
self.errors = errors
|
|
|
|
|
|
def schemas_dir() -> Path:
|
|
"""Resolve the frozen schemas dir, honoring the M2_MARKET_SCHEMAS_DIR override."""
|
|
override = os.environ.get("M2_MARKET_SCHEMAS_DIR")
|
|
if override:
|
|
return Path(override)
|
|
return Path(__file__).resolve().parents[3] / "schemas"
|
|
|
|
|
|
def _load_json(path: Path) -> dict:
|
|
with path.open(encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def _validate(instance: dict, schema_path: Path, label: str) -> list[str]:
|
|
schema = _load_json(schema_path)
|
|
validator = Draft202012Validator(schema)
|
|
errors = []
|
|
for err in sorted(validator.iter_errors(instance), key=lambda e: list(map(str, e.path))):
|
|
loc = "/".join(str(p) for p in err.path) or "<root>"
|
|
errors.append(f"{label}: {loc}: {err.message}")
|
|
return errors
|
|
|
|
|
|
def validate_bundle(bundle_dir: Path) -> tuple[dict, dict]:
|
|
"""Validate BUNDLE_DIR/solution.json + listing.json against the frozen schemas.
|
|
|
|
Returns (solution, listing) on success; raises PublishValidationError
|
|
(one message per violation, including tenant-firewall rules encoded in
|
|
solution.schema.json's if/else) otherwise.
|
|
"""
|
|
solution_path = bundle_dir / "solution.json"
|
|
listing_path = bundle_dir / "listing.json"
|
|
|
|
errors: list[str] = []
|
|
solution: dict | None = None
|
|
listing: dict | None = None
|
|
|
|
try:
|
|
solution = _load_json(solution_path)
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
errors.append(f"solution.json: {exc}")
|
|
try:
|
|
listing = _load_json(listing_path)
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
errors.append(f"listing.json: {exc}")
|
|
|
|
if errors:
|
|
raise PublishValidationError(errors)
|
|
|
|
schemas = schemas_dir()
|
|
errors += _validate(solution, schemas / "solution.schema.json", "solution.json")
|
|
errors += _validate(listing, schemas / "listing.schema.json", "listing.json")
|
|
|
|
if errors:
|
|
raise PublishValidationError(errors)
|
|
|
|
return solution, listing
|
|
|
|
|
|
def build_bundle_tarball(bundle_dir: Path, solution: dict, dest_path: Path) -> str:
|
|
"""Tar payload/+recipe.yaml+solution.json into dest_path; return its sha256:<hex> hash."""
|
|
payload_dir = bundle_dir / "payload"
|
|
recipe_path = bundle_dir / "recipe.yaml"
|
|
|
|
with tarfile.open(dest_path, "w:gz") as tar:
|
|
if payload_dir.is_dir():
|
|
tar.add(payload_dir, arcname="payload")
|
|
if recipe_path.is_file():
|
|
tar.add(recipe_path, arcname="recipe.yaml")
|
|
solution_bytes = json.dumps(solution, indent=2, sort_keys=True).encode("utf-8")
|
|
info = tarfile.TarInfo(name="solution.json")
|
|
info.size = len(solution_bytes)
|
|
tar.addfile(info, io.BytesIO(solution_bytes))
|
|
|
|
digest = hashlib.sha256()
|
|
with dest_path.open("rb") as fh:
|
|
for chunk in iter(lambda: fh.read(65536), b""):
|
|
digest.update(chunk)
|
|
return f"sha256:{digest.hexdigest()}"
|
|
|
|
|
|
def render_pr_body(solution: dict, listing: dict) -> str:
|
|
"""PR template body (registry-layout.md publish protocol step 2)."""
|
|
permissions = solution.get("permissions") or []
|
|
permissions_lines = "\n".join(f"- `{perm}`" for perm in permissions) or "- (none)"
|
|
price = solution.get("price") or {}
|
|
tenant_scope = solution.get("tenant_scope", "-")
|
|
|
|
body = (
|
|
f"## Evidence summary\n{listing.get('evidence_summary', '-')}\n\n"
|
|
f"## Price\n{price.get('amount', '-')} {price.get('currency', '-')}\n\n"
|
|
f"## Permissions\n{permissions_lines}\n\n"
|
|
f"## Rollback\nRe-run `m2-market install` after uninstall; the apply adapter "
|
|
f"restores pre-install backups (contracts/apply-adapter.md).\n"
|
|
)
|
|
|
|
if tenant_scope != "m2-core":
|
|
owner_initiated = bool(solution.get("owner_initiated"))
|
|
double_scrubbed = bool((solution.get("scrub_status") or {}).get("double_scrubbed"))
|
|
body += (
|
|
"\n## Tenant-derived attestations\n"
|
|
f"- [{'x' if owner_initiated else ' '}] owner_initiated\n"
|
|
f"- [{'x' if double_scrubbed else ' '}] double_scrubbed\n"
|
|
)
|
|
|
|
return body
|
|
|
|
|
|
def publish_bundle(bundle_dir: Path, solution: dict, listing: dict, registry: RegistryClient) -> str:
|
|
"""Run the publish protocol (branch -> commit -> release -> PR); returns the PR URL."""
|
|
listing_id = listing["listing_id"]
|
|
branch = f"listing/{listing_id}"
|
|
install_ref = listing["install_ref"]
|
|
|
|
with tempfile.TemporaryDirectory(prefix="m2-market-publish-") as tmp_dir_str:
|
|
tarball_path = Path(tmp_dir_str) / f"{install_ref}.tar.gz"
|
|
content_hash = build_bundle_tarball(bundle_dir, solution, tarball_path)
|
|
|
|
solution = dict(solution)
|
|
solution["content_hash"] = content_hash
|
|
|
|
registry.create_branch(branch)
|
|
registry.create_file(
|
|
f"listings/{listing_id}/listing.json",
|
|
json.dumps(listing, indent=2, sort_keys=True).encode("utf-8") + b"\n",
|
|
message=f"listing({listing_id}): add listing.json",
|
|
branch=branch,
|
|
)
|
|
registry.create_file(
|
|
f"listings/{listing_id}/solution.json",
|
|
json.dumps(solution, indent=2, sort_keys=True).encode("utf-8") + b"\n",
|
|
message=f"listing({listing_id}): add solution.json",
|
|
branch=branch,
|
|
)
|
|
|
|
release = registry.create_release(
|
|
tag_name=install_ref,
|
|
target_commitish=branch,
|
|
name=install_ref,
|
|
body=f"Bundle release for {listing_id}",
|
|
)
|
|
registry.upload_release_asset(release["id"], tarball_path.name, tarball_path.read_bytes())
|
|
|
|
pr = registry.create_pull_request(
|
|
title=f"listing: {listing.get('name', listing_id)} ({listing_id})",
|
|
body=render_pr_body(solution, listing),
|
|
head=branch,
|
|
base="main",
|
|
)
|
|
|
|
return pr["html_url"]
|