target-revenue/scripts/trf_onboard.py

137 lines
5 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Trust Service onboarding CLI (WP-0006-T07).
Mechanism only see `specs/TrustServiceOnboarding.md` for the full
process this wraps. Deliberately dependency-light: stdlib `urllib` for
HTTP (no `requests`/`httpx` requirement) plus `target_revenue.validation`
(already a core, non-service dependency) for offline pre-flight checks
a repo onboarding a Phase should not need to install FastAPI/psycopg just
to register one.
Every subcommand that submits data validates it offline first, with the
same check the hosted service itself runs at registration
(`validation.py`), so a rejection is caught locally with an identical
field-by-field diff instead of only being discovered via a failed network
call (specs/TrustServiceOnboarding.md §3 steps 2 and 4).
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "src"))
from target_revenue import validation # noqa: E402
def _load(path: str) -> dict:
with open(path, encoding="utf-8") as f:
return json.load(f)
def _request(url: str, method: str, token: str | None = None, body: dict | None = None) -> dict:
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8")
raise SystemExit(f"{method} {url} -> HTTP {exc.code}: {detail}") from exc
def _token_from_env(env_var: str) -> str:
token = os.environ.get(env_var)
if not token:
raise SystemExit(
f"environment variable {env_var!r} is not set — the Licensor "
"API token must never be passed on the command line or "
"committed to the repo (specs/TrustServiceOnboarding.md §2)"
)
return token
def cmd_validate(args: argparse.Namespace) -> None:
manifest = _load(args.manifest)
try:
validation.validate_phase_manifest(manifest)
except validation.ConformanceError as exc:
print("REJECTED:", "; ".join(exc.errors), file=sys.stderr)
raise SystemExit(1)
print(f"OK: {manifest['phase']['id']} is schema-conformant")
def cmd_register_phase(args: argparse.Namespace) -> None:
manifest = _load(args.manifest)
try:
validation.validate_phase_manifest(manifest)
except validation.ConformanceError as exc:
print("REJECTED (offline check, no network call made):", "; ".join(exc.errors), file=sys.stderr)
raise SystemExit(1)
token = _token_from_env(args.token_env)
result = _request(f"{args.url}/phases", "POST", token=token, body=manifest)
print(json.dumps(result, indent=2))
def cmd_append_entry(args: argparse.Namespace) -> None:
entry = _load(args.entry)
token = _token_from_env(args.token_env)
result = _request(
f"{args.url}/phases/{args.phase_id}/ledger", "POST", token=token, body=entry
)
print(json.dumps(result, indent=2))
def cmd_status(args: argparse.Namespace) -> None:
result = _request(f"{args.url}/phases/{args.phase_id}/metrics", "GET")
print(json.dumps(result, indent=2))
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="trf_onboard", description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
p_validate = sub.add_parser("validate", help="offline-validate a Phase Manifest file, no network call")
p_validate.add_argument("manifest")
p_validate.set_defaults(func=cmd_validate)
p_register = sub.add_parser("register-phase", help="validate offline, then POST /phases")
p_register.add_argument("--url", required=True, help="Trust Service base URL")
p_register.add_argument("--token-env", required=True, help="env var holding the Licensor API token")
p_register.add_argument("--manifest", required=True)
p_register.set_defaults(func=cmd_register_phase)
p_append = sub.add_parser("append-entry", help="POST a Ledger entry for an already-registered Phase")
p_append.add_argument("--url", required=True)
p_append.add_argument("--token-env", required=True)
p_append.add_argument("--phase-id", required=True)
p_append.add_argument("--entry", required=True)
p_append.set_defaults(func=cmd_append_entry)
p_status = sub.add_parser("status", help="GET /phases/{id}/metrics (public, no token)")
p_status.add_argument("--url", required=True)
p_status.add_argument("--phase-id", required=True)
p_status.set_defaults(func=cmd_status)
return parser
def main(argv: list[str] | None = None) -> None:
parser = build_parser()
args = parser.parse_args(argv)
args.func(args)
if __name__ == "__main__":
main()