generated from coulomb/repo-seed
Add registry feed and library inventory
This commit is contained in:
@@ -1,9 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .loader import load_yaml
|
||||
from .graph import FabricGraph, build_graph
|
||||
from .validation import validate_roots
|
||||
|
||||
@@ -53,6 +60,26 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
export = sub.add_parser("export", help="Export graph as JSON or Mermaid.")
|
||||
export.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
|
||||
export.add_argument("--format", choices=["json", "mermaid"], default="json")
|
||||
|
||||
registry = sub.add_parser("registry", help="Feed a running Railiance Fabric registry service.")
|
||||
registry_sub = registry.add_subparsers(dest="registry_command", required=True)
|
||||
|
||||
sync = registry_sub.add_parser("sync", help="Register a repo and ingest its current graph snapshot.")
|
||||
sync.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
|
||||
sync.add_argument("--registry-url", default="http://127.0.0.1:8765")
|
||||
sync.add_argument("--repo-slug", default=None)
|
||||
sync.add_argument("--name", default=None)
|
||||
sync.add_argument("--remote-url", default=None)
|
||||
sync.add_argument("--default-branch", default="main")
|
||||
sync.add_argument("--state-hub-repo-id", default=None)
|
||||
sync.add_argument("--commit", default=None)
|
||||
sync.add_argument("--json", action="store_true", help="Print the raw snapshot response.")
|
||||
|
||||
cyclonedx = registry_sub.add_parser("ingest-cyclonedx", help="Ingest a CycloneDX SBOM as library inventory.")
|
||||
cyclonedx.add_argument("sbom", type=Path)
|
||||
cyclonedx.add_argument("--registry-url", default="http://127.0.0.1:8765")
|
||||
cyclonedx.add_argument("--repo-slug", required=True)
|
||||
cyclonedx.add_argument("--json", action="store_true", help="Print the raw ingest response.")
|
||||
return parser
|
||||
|
||||
|
||||
@@ -101,9 +128,128 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(graph.to_mermaid() if args.format == "mermaid" else graph.to_json())
|
||||
return 0
|
||||
|
||||
if args.command == "registry":
|
||||
if args.registry_command == "sync":
|
||||
return _registry_sync(args)
|
||||
if args.registry_command == "ingest-cyclonedx":
|
||||
return _registry_ingest_cyclonedx(args)
|
||||
|
||||
parser.error(f"unknown command {args.command!r}")
|
||||
return 2
|
||||
|
||||
|
||||
def _registry_sync(args: argparse.Namespace) -> int:
|
||||
report = validate_roots(args.paths)
|
||||
for diagnostic in report.diagnostics:
|
||||
print(diagnostic.format(), file=sys.stderr)
|
||||
if report.errors:
|
||||
print(report.summary(), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
graph = _load_graph_or_exit(args.paths)
|
||||
repo_path = _primary_repo_path(args.paths)
|
||||
repo_slug = args.repo_slug or _slugify(repo_path.name)
|
||||
repository = _registry_post(
|
||||
args.registry_url,
|
||||
"/repositories",
|
||||
{
|
||||
"slug": repo_slug,
|
||||
"name": args.name or repo_path.name,
|
||||
"remote_url": args.remote_url or _git_value(repo_path, "config", "--get", "remote.origin.url"),
|
||||
"default_branch": args.default_branch,
|
||||
"state_hub_repo_id": args.state_hub_repo_id,
|
||||
},
|
||||
)
|
||||
snapshot = _registry_post(
|
||||
args.registry_url,
|
||||
f"/repositories/{repo_slug}/snapshots",
|
||||
{
|
||||
"commit": args.commit or _git_value(repo_path, "rev-parse", "HEAD") or "working-tree",
|
||||
"generated_at": _utc_now(),
|
||||
"graph": graph.to_export(),
|
||||
},
|
||||
)
|
||||
if args.json:
|
||||
print(json.dumps({"repository": repository, "snapshot": snapshot}, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(f"registered {repository['slug']}")
|
||||
print(f"snapshot {snapshot['id']} accepted for {snapshot['commit']}")
|
||||
return 0
|
||||
|
||||
|
||||
def _registry_ingest_cyclonedx(args: argparse.Namespace) -> int:
|
||||
payload = load_yaml(args.sbom)
|
||||
if not isinstance(payload, dict):
|
||||
print(f"ERROR {args.sbom}: CycloneDX SBOM must be a mapping/object", file=sys.stderr)
|
||||
return 1
|
||||
result = _registry_post(
|
||||
args.registry_url,
|
||||
f"/repositories/{args.repo_slug}/libraries/cyclonedx",
|
||||
payload,
|
||||
)
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(f"ingested {result['component_count']} library component(s) for {result['repo_slug']}")
|
||||
return 0
|
||||
|
||||
|
||||
def _registry_post(registry_url: str, path: str, payload: dict[str, object]) -> dict[str, object]:
|
||||
data = json.dumps({key: value for key, value in payload.items() if value is not None}).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
registry_url.rstrip("/") + path,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
body = json.loads(response.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
print(f"ERROR registry request failed ({exc.code}): {detail}", file=sys.stderr)
|
||||
raise SystemExit(1) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
print(f"ERROR cannot reach registry at {registry_url}: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1) from exc
|
||||
if not isinstance(body, dict):
|
||||
print("ERROR registry returned a non-object response", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
return body
|
||||
|
||||
|
||||
def _primary_repo_path(paths: list[Path]) -> Path:
|
||||
if not paths:
|
||||
return Path(".").resolve()
|
||||
path = paths[0].resolve()
|
||||
return path.parent if path.is_file() else path
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]", "-", value.lower())).strip("-") or "repo"
|
||||
|
||||
|
||||
def _git_value(repo_path: Path, *args: str) -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=repo_path,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
value = result.stdout.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
def _load_graph_or_exit(paths: list[Path]) -> FabricGraph:
|
||||
graph = build_graph(paths)
|
||||
if graph.load_errors:
|
||||
|
||||
Reference in New Issue
Block a user